From 8d07522a75eb7226940f6886c5666a12d8082fcd Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Sun, 8 Mar 2026 12:02:32 +0100 Subject: [PATCH 1/4] feat: implement payroll scheduling engine with timezone support and idempotency --- backend/package.json | 136 ++-- backend/src/app.ts | 85 ++- backend/src/config/database.ts | 4 +- backend/src/controllers/paymentController.ts | 4 +- backend/src/index.ts | 86 +-- backend/src/routes/contractEventRoutes.ts | 20 - backend/src/routes/paymentRoutes.ts | 2 +- backend/src/services/scheduleExecutor.ts | 56 +- backend/src/services/scheduleService.ts | 117 ++- .../src/services/transactionAuditService.ts | 2 +- backend/tsconfig.json | 67 +- frontend/src/hooks/useWallet.ts | 3 +- frontend/src/hooks/useWalletSigning.ts | 10 +- frontend/src/pages/CrossAssetPayment.tsx | 364 +++++---- frontend/src/pages/TransactionHistory.tsx | 689 +++++------------- frontend/src/providers/WalletProvider.tsx | 124 ++-- package.json | 13 +- 17 files changed, 802 insertions(+), 980 deletions(-) diff --git a/backend/package.json b/backend/package.json index 58bb6bc4..4b923526 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,57 +1,83 @@ { - "name": "backend", - "version": "1.0.0", - "type": "module", - "description": "", - "main": "src/index.ts", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "test": "jest", - "test:benchmark": "ts-node src/benchmarks/sds-vs-horizon.benchmark.ts", - "lint": "eslint src/**/*.ts", - "db:migrate": "ts-node src/db/migrate.ts", - "db:migrate:dry-run": "ts-node src/db/migrate.ts --dry-run", - "db:verify-schema": "ts-node src/db/verify-schema.ts", - "db:verify-schedules": "ts-node src/db/verify-schedules-schema.ts" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@sentry/node": "^10.42.0", - "@sentry/profiling-node": "^10.42.0", - "@stellar/stellar-sdk": "^14.3.3", - "cors": "^2.8.6", - "dotenv": "^17.3.1", - "express": "^5.2.1", - "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.3", - "node-cron": "^4.2.1", - "passport": "^0.7.0", - "passport-github2": "^0.1.12", - "passport-google-oauth20": "^2.0.0", - "pg": "^8.18.0", - "toml": "^3.0.0", - "zod": "^4.3.6" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^25.3.0", - "@types/node-cron": "^3.0.11", - "@types/passport": "^1.0.17", - "@types/passport-github2": "^1.2.9", - "@types/passport-google-oauth20": "^2.0.17", - "@types/pg": "^8.16.0", - "@types/supertest": "^7.2.0", - "jest": "^30.2.0", - "supertest": "^7.2.2", - "ts-jest": "^29.4.6", - "ts-node-dev": "^2.0.0", - "typescript": "^5.9.3" - } + "name": "payd-backend", + "version": "1.0.0", + "type": "module", + "description": "PayD Backend API with SDS Integration", + "main": "src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "jest", + "test:benchmark": "ts-node src/benchmarks/sds-vs-horizon.benchmark.ts", + "lint": "eslint src/**/*.ts", + "db:migrate": "ts-node src/db/migrate.ts", + "db:migrate:dry-run": "ts-node src/db/migrate.ts --dry-run", + "db:verify-schema": "ts-node src/db/verify-schema.ts", + "db:verify-schedules": "ts-node src/db/verify-schedules-schema.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@otplib/preset-default": "^12.0.1", + "@stellar/stellar-sdk": "^12.1.0", + "@types/qrcode": "^1.5.6", + "axios": "^1.6.5", + "cors": "^2.8.6", + "crypto-js": "^4.2.0", + "dotenv": "^17.3.1", + "exceljs": "^4.4.0", + "express": "^5.2.1", + "fast-csv": "^5.0.5", + "helmet": "^8.1.0", + "ioredis": "^5.9.3", + "jsonwebtoken": "^9.0.3", + "luxon": "^3.7.2", + "morgan": "^1.10.1", + "node-cron": "^4.2.1", + "otplib": "^13.3.0", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", + "pdfkit": "^0.17.2", + "pg": "^8.18.0", + "qrcode": "^1.5.4", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "toml": "^3.0.0", + "uuid": "^9.0.1", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/crypto-js": "^4.2.2", + "@types/express": "^5.0.6", + "@types/ioredis": "^4.28.10", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.10", + "@types/luxon": "^3.7.1", + "@types/morgan": "^1.9.10", + "@types/node": "^25.3.0", + "@types/node-cron": "^3.0.11", + "@types/passport": "^1.0.17", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", + "@types/pdfkit": "^0.17.5", + "@types/pg": "^8.16.0", + "@types/supertest": "^7.2.0", + "@typescript-eslint/eslint-plugin": "^6.16.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint": "^8.56.0", + "fast-check": "^4.5.3", + "jest": "^30.2.0", + "prettier": "^3.1.1", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } } \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index 6ffa5511..4dade140 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,57 +1,69 @@ import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; -import config from './config'; -import logger from './utils/logger'; -import payrollRoutes from './routes/payroll.routes'; -import authRoutes from './routes/authRoutes'; -import employeeRoutes from './routes/employeeRoutes'; -import assetRoutes from './routes/assetRoutes'; -import paymentRoutes from './routes/paymentRoutes'; -import searchRoutes from './routes/searchRoutes'; -import scheduleRoutes from './routes/scheduleRoutes'; -import contractEventRoutes from './routes/contractEventRoutes'; -import * as Sentry from '@sentry/node'; -import { nodeProfilingIntegration } from '@sentry/profiling-node'; +import helmet from 'helmet'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import passport from './config/passport.js'; +import { apiVersionMiddleware } from './middlewares/apiVersionMiddleware.js'; -const app = express(); +// Feature Routes +import v1Routes from './routes/v1/index.js'; +import authRoutes from './routes/authRoutes.js'; +import webhookRoutes from './routes/webhook.routes.js'; -// Sentry Initialization -Sentry.init({ - dsn: config.sentry?.dsn || process.env.SENTRY_DSN, - integrations: [ - nodeProfilingIntegration(), - ], - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions (modify in production) - // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: 1.0, -}); +// Upstream Routes +import payrollRoutes from './routes/payroll.routes.js'; +import employeeRoutes from './routes/employeeRoutes.js'; +import assetRoutes from './routes/assetRoutes.js'; +import paymentRoutes from './routes/paymentRoutes.js'; +import searchRoutes from './routes/searchRoutes.js'; +import contractRoutes from './routes/contractRoutes.js'; + +// My Routes +import scheduleRoutes from './routes/scheduleRoutes.js'; +import contractEventRoutes from './routes/contractEventRoutes.js'; -// Sentry naturally instruments Express when initialized +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); // Middleware +app.use(helmet()); app.use(cors()); app.use(morgan('combined')); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use(passport.initialize()); -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }); +// Serve stellar.toml for SEP-0001 +app.get('/.well-known/stellar.toml', (req, res) => { + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.sendFile(path.join(__dirname, '../.well-known/stellar.toml')); }); -// API routes +// Middleware for versioning +app.use(apiVersionMiddleware); + +// Feature / PR specific routes +app.use('/auth', authRoutes); +app.use('/api/v1', v1Routes); +app.use('/webhooks', webhookRoutes); + +// Upstream / Base routes app.use('/api/auth', authRoutes); app.use('/api/payroll', payrollRoutes); app.use('/api/employees', employeeRoutes); app.use('/api/assets', assetRoutes); app.use('/api/payments', paymentRoutes); app.use('/api/search', searchRoutes); +app.use('/api', contractRoutes); + +// Feature specific routes app.use('/api/schedules', scheduleRoutes); app.use('/api/events', contractEventRoutes); @@ -63,13 +75,6 @@ app.use((req, res) => { }); }); -app.get('/debug-sentry', function mainHandler(req, res) { - throw new Error('My first Sentry error!'); -}); - -// Sentry Error handler must be before any other error middleware and after all controllers -Sentry.setupExpressErrorHandler(app); - // Error handler app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error', err); diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 7679b7a0..bd58cc3d 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -1,5 +1,5 @@ import { Pool } from 'pg'; -import dotenv from 'dotenv'; +import * as dotenv from 'dotenv'; dotenv.config(); @@ -8,5 +8,5 @@ const pool = new Pool({ }); export const query = (text: string, params?: any[]) => pool.query(text, params); - +export { pool }; export default pool; diff --git a/backend/src/controllers/paymentController.ts b/backend/src/controllers/paymentController.ts index ed5340d7..28efcfac 100644 --- a/backend/src/controllers/paymentController.ts +++ b/backend/src/controllers/paymentController.ts @@ -1,4 +1,4 @@ -import type { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { AnchorService } from '../services/anchorService.js'; import { Keypair, Asset } from '@stellar/stellar-sdk'; import { StellarService } from '../services/stellarService.js'; @@ -62,7 +62,7 @@ export class PaymentController { // For simplicity in this implementation, we re-auth const token = await AnchorService.authenticate(domain as string, clientKeypair); - const status = await AnchorService.getTransaction(domain as string, token, id); + const status = await AnchorService.getTransaction(domain as string, token, id as string); res.json(status); } catch (error: any) { res.status(500).json({ error: error.message }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 973f772e..ff2890e8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,87 +1,39 @@ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; import dotenv from 'dotenv'; -import passport from './config/passport.js'; -import authRoutes from './routes/authRoutes.js'; +import { createServer } from 'http'; +import app from './app.js'; +import logger from './utils/logger.js'; +import config from './config/index.js'; +import { initializeSocket } from './services/socketService.js'; import { scheduleExecutor } from './services/scheduleExecutor.js'; import { contractEventIndexer } from './services/contractEventIndexer.js'; -import { LedgerObserverService } from './services/ledgerObserverService.js'; dotenv.config(); -const app = express(); -const PORT = process.env.PORT || 4000; +const server = createServer(app); -app.use(helmet()); -app.use(cors()); -app.use(express.json()); -app.use(passport.initialize()); +// Initialize Socket.IO +initializeSocket(server); -app.get('/.well-known/stellar.toml', (req, res) => { - const issuer = process.env.ORGUSD_ISSUER_PUBLIC; - const networkPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE; +const PORT = config.port || process.env.PORT || 4000; - if (!issuer) { - res.status(503).json({ - error: 'Service Unavailable', - message: 'ORGUSD_ISSUER_PUBLIC is not configured', - }); - return; - } +server.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + logger.info(`Environment: ${config.nodeEnv}`); + logger.info(`Health check: http://localhost:${PORT}/health`); + logger.info(`Contract registry: http://localhost:${PORT}/api/contracts`); - const toml = [ - 'VERSION="2.0.0"', - networkPassphrase ? `NETWORK_PASSPHRASE="${networkPassphrase}"` : null, - '', - '[DOCUMENTATION]', - 'ORG_NAME="PayD"', - 'ORG_URL="https://github.com/pope-h/PayD"', - 'ORG_DESCRIPTION="PayD is a Stellar-based cross-border payroll platform."', - 'ORG_GITHUB="pope-h/PayD"', - 'ORG_OFFICIAL_EMAIL="support@example.com"', - '', - '[[CURRENCIES]]', - 'code="ORGUSD"', - `issuer="${issuer}"`, - 'display_decimals=2', - 'name="ORGUSD"', - 'desc="Organization-specific stablecoin used for payroll disbursements on Stellar."', - ] - .filter((line): line is string => Boolean(line)) - .join('\n'); - - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.status(200).send(toml); -}); - -// Routes -app.use('/auth', authRoutes); - -app.get('/health', (req, res) => { - res.json({ status: 'ok' }); -}); - -const server = app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); // Initialize ScheduleExecutor after server starts scheduleExecutor.initialize(); - console.log('ScheduleExecutor initialized'); + logger.info('ScheduleExecutor initialized'); // Initialize ContractEventIndexer contractEventIndexer.initialize(); - console.log('ContractEventIndexer initialized'); - - // Start the Ledger Observer Service to listen for Stellar events - LedgerObserverService.start().catch((err: any) => { - console.error('Failed to start LedgerObserverService:', err); - }); + logger.info('ContractEventIndexer initialized'); }); // Graceful shutdown handling const shutdown = () => { - console.log('Shutting down gracefully...'); + logger.info('Shutting down gracefully...'); // Stop the schedule executor scheduleExecutor.stop(); @@ -91,13 +43,13 @@ const shutdown = () => { // Close the server server.close(() => { - console.log('Server closed'); + logger.info('Server closed'); process.exit(0); }); // Force shutdown after 10 seconds setTimeout(() => { - console.error('Forced shutdown after timeout'); + logger.error('Forced shutdown after timeout'); process.exit(1); }, 10000); }; diff --git a/backend/src/routes/contractEventRoutes.ts b/backend/src/routes/contractEventRoutes.ts index 087fc552..94199318 100644 --- a/backend/src/routes/contractEventRoutes.ts +++ b/backend/src/routes/contractEventRoutes.ts @@ -11,9 +11,6 @@ router.use(isolateOrganization); /** * @route GET /api/events/indexer/status - * @desc Get indexer status and last indexed ledger - * @access Private - Requires authentication - * @returns {IndexerState} Indexer status information */ router.get( '/indexer/status', @@ -22,15 +19,6 @@ router.get( /** * @route GET /api/events/:contractId - * @desc Get paginated events for a specific contract - * @access Private - Requires authentication - * @param {string} contractId - Contract ID (Stellar address) - * @query {string} eventType - Optional filter by event type - * @query {number} fromLedger - Optional filter from ledger sequence - * @query {number} toLedger - Optional filter to ledger sequence - * @query {number} page - Page number (default: 1) - * @query {number} limit - Items per page (default: 20, max: 100) - * @returns {PaginatedContractEvents} Paginated list of contract events */ router.get( '/:contractId', @@ -39,14 +27,6 @@ router.get( /** * @route GET /api/events - * @desc Get all events across all contracts for the organization - * @access Private - Requires authentication - * @query {string} eventType - Optional filter by event type - * @query {number} fromLedger - Optional filter from ledger sequence - * @query {number} toLedger - Optional filter to ledger sequence - * @query {number} page - Page number (default: 1) - * @query {number} limit - Items per page (default: 20, max: 100) - * @returns {PaginatedContractEvents} Paginated list of contract events */ router.get( '/', diff --git a/backend/src/routes/paymentRoutes.ts b/backend/src/routes/paymentRoutes.ts index 95ffb3e0..3caa740d 100644 --- a/backend/src/routes/paymentRoutes.ts +++ b/backend/src/routes/paymentRoutes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { PaymentController } from '../controllers/paymentController.js'; import { require2FA } from '../middlewares/require2fa.js'; -import authenticateJWT from '../middlewares/auth.js'; +import { authenticateJWT } from '../middlewares/auth.js'; import { isolateOrganization } from '../middlewares/rbac.js'; const router = Router(); diff --git a/backend/src/services/scheduleExecutor.ts b/backend/src/services/scheduleExecutor.ts index 3e6b21f7..10f81659 100644 --- a/backend/src/services/scheduleExecutor.ts +++ b/backend/src/services/scheduleExecutor.ts @@ -1,4 +1,5 @@ import cron from 'node-cron'; +import type { ScheduledTask } from 'node-cron'; import { default as pool } from '../config/database.js'; import { StellarService } from './stellarService.js'; import { scheduleService } from './scheduleService.js'; @@ -6,7 +7,7 @@ import type { Schedule, ExecutionResult, PaymentRecipient } from '../types/sched import { Operation, Asset, Memo, Keypair } from '@stellar/stellar-sdk'; export class ScheduleExecutor { - private cronJob: cron.ScheduledTask | null = null; + private cronJob: ScheduledTask | null = null; /** * Initialize the cron job to run every minute @@ -54,20 +55,23 @@ export class ScheduleExecutor { start_date as "startDate", end_date as "endDate", payment_config as "paymentConfig", + timezone, next_run_timestamp as "nextRunTimestamp", last_run_timestamp as "lastRunTimestamp", status, created_at as "createdAt", updated_at as "updatedAt" FROM schedules - WHERE next_run_timestamp <= NOW() AND status = 'active' + WHERE next_run_timestamp <= (NOW() AT TIME ZONE 'UTC') AND status = 'active' ORDER BY next_run_timestamp ASC `; const result = await client.query(query); const dueSchedules = result.rows; - console.log(`[ScheduleExecutor] Found ${dueSchedules.length} due schedule(s)`); + if (dueSchedules.length > 0) { + console.log(`[ScheduleExecutor] Found ${dueSchedules.length} due schedule(s)`); + } let successCount = 0; let failureCount = 0; @@ -88,17 +92,21 @@ export class ScheduleExecutor { updatedAt: new Date(scheduleRow.updatedAt), }; - console.log(`[ScheduleExecutor] Executing schedule ID ${schedule.id}`); - + // Idempotency check: Ensure we haven't already processed this exact run + // We can check if last_run_timestamp is very close to now AND next_run_timestamp hasn't updated yet + // But a better way is to rely on the transaction in recordExecution which updates the status/nextRun + + console.log(`[ScheduleExecutor] Executing schedule ID ${schedule.id} (Scheduled for: ${schedule.nextRunTimestamp.toISOString()})`); + // Execute the schedule const executionResult = await this.executeSchedule(schedule); - - // Record the execution + + // Record the execution (this updates next_run_timestamp or status) await this.recordExecution(schedule.id, executionResult); if (executionResult.success) { successCount++; - console.log(`[ScheduleExecutor] Schedule ID ${schedule.id} executed successfully`); + console.log(`[ScheduleExecutor] Schedule ID ${schedule.id} executed successfully. Hash: ${executionResult.transactionHash}`); } else { failureCount++; console.error( @@ -112,14 +120,14 @@ export class ScheduleExecutor { `[ScheduleExecutor] Error processing schedule ID ${scheduleRow.id}:`, error ); - - // Record the failure + + // Record the system error as a failure try { await this.recordExecution(scheduleRow.id, { success: false, error: { - message: error instanceof Error ? error.message : 'Unknown error', - details: error, + message: error instanceof Error ? error.message : 'System error in executor', + details: error as any, }, }); } catch (recordError) { @@ -131,9 +139,11 @@ export class ScheduleExecutor { } } - console.log( - `[ScheduleExecutor] Execution complete - Success: ${successCount}, Failed: ${failureCount}` - ); + if (dueSchedules.length > 0) { + console.log( + `[ScheduleExecutor] Execution complete - Success: ${successCount}, Failed: ${failureCount}` + ); + } } finally { client.release(); } @@ -148,7 +158,7 @@ export class ScheduleExecutor { try { // Extract payment configuration const paymentConfig = schedule.paymentConfig; - + if (!paymentConfig || !paymentConfig.recipients || paymentConfig.recipients.length === 0) { throw new Error('Invalid payment configuration: no recipients found'); } @@ -186,13 +196,17 @@ export class ScheduleExecutor { }); // Build transaction using StellarService + const txOptions: { fee?: string; timeout?: number; memo?: Memo } = { + timeout: 30, + }; + if (paymentConfig.memo) { + txOptions.memo = Memo.text(paymentConfig.memo); + } + const builder = await StellarService.buildTransaction( sourceKeypair.publicKey(), operations, - { - memo: paymentConfig.memo ? Memo.text(paymentConfig.memo) : undefined, - timeout: 30, - } + txOptions ); const transaction = builder.build(); @@ -210,7 +224,7 @@ export class ScheduleExecutor { } catch (error) { // Parse Stellar error for better error messages const parsedError = StellarService.parseError(error); - + return { success: false, error: { diff --git a/backend/src/services/scheduleService.ts b/backend/src/services/scheduleService.ts index 54fbc8e9..5072bd34 100644 --- a/backend/src/services/scheduleService.ts +++ b/backend/src/services/scheduleService.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import { default as pool } from '../config/database.js'; import type { Schedule, @@ -9,58 +10,81 @@ import type { export class ScheduleService { /** - * Calculate the next run timestamp for a schedule based on frequency + * Calculate the next run timestamp for a schedule based on frequency and timezone * @param frequency - Schedule frequency ('once', 'weekly', 'biweekly', 'monthly') * @param timeOfDay - Time of day in HH:MM format * @param startDate - Start date for the schedule + * @param timezone - Timezone for the schedule (e.g., 'America/New_York') * @param lastRun - Optional last run timestamp for recurring schedules - * @returns Date object representing the next execution time + * @returns Date object representing the next execution time (in UTC) */ calculateNextRun( frequency: ScheduleFrequency, timeOfDay: string, startDate: Date, + timezone: string, lastRun?: Date, ): Date { - // Parse time of day (HH:MM format) const [hours, minutes] = timeOfDay.split(':').map(Number); - // For 'once' frequency, return startDate + timeOfDay + // Initial reference point in the specified timezone + let referenceDateTime: DateTime; + if (frequency === 'once') { - const nextRun = new Date(startDate); - nextRun.setHours(hours, minutes, 0, 0); - return nextRun; + referenceDateTime = DateTime.fromJSDate(startDate, { zone: timezone }).set({ + hour: hours, + minute: minutes, + second: 0, + millisecond: 0, + }); + return referenceDateTime.toJSDate(); } // For recurring schedules, use lastRun if provided, otherwise use startDate - const baseDate = lastRun ? new Date(lastRun) : new Date(startDate); - const nextRun = new Date(baseDate); + if (lastRun) { + referenceDateTime = DateTime.fromJSDate(lastRun, { zone: timezone }); + } else { + referenceDateTime = DateTime.fromJSDate(startDate, { zone: timezone }).set({ + hour: hours, + minute: minutes, + second: 0, + millisecond: 0, + }); + + // If we are calculating for the first time and the derived time is in the past, + // it means we should probably calculate the *next* occurrence. + // But usually, createSchedule will set the first run to the user's intent. + } + + let nextRun: DateTime = referenceDateTime; // Calculate next occurrence based on frequency switch (frequency) { case 'weekly': - // Add 7 days - nextRun.setDate(nextRun.getDate() + 7); + nextRun = referenceDateTime.plus({ weeks: 1 }); break; case 'biweekly': - // Add 14 days - nextRun.setDate(nextRun.getDate() + 14); + nextRun = referenceDateTime.plus({ weeks: 2 }); break; case 'monthly': - // Add 1 month - nextRun.setMonth(nextRun.getMonth() + 1); + nextRun = referenceDateTime.plus({ months: 1 }); break; default: throw new Error(`Unsupported frequency: ${frequency}`); } - // Set the time of day - nextRun.setHours(hours, minutes, 0, 0); + // Ensure the time of day is preserved in the target timezone + nextRun = nextRun.set({ + hour: hours, + minute: minutes, + second: 0, + millisecond: 0, + }); - return nextRun; + return nextRun.toJSDate(); } async createSchedule( @@ -84,6 +108,7 @@ export class ScheduleService { scheduleData.frequency, scheduleData.timeOfDay, startDate, + scheduleData.timezone, ); // Insert schedule into database @@ -96,10 +121,11 @@ export class ScheduleService { start_date, end_date, payment_config, + timezone, next_run_timestamp, status ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, organization_id as "organizationId", @@ -109,6 +135,7 @@ export class ScheduleService { start_date as "startDate", end_date as "endDate", payment_config as "paymentConfig", + timezone, next_run_timestamp as "nextRunTimestamp", last_run_timestamp as "lastRunTimestamp", status, @@ -124,6 +151,7 @@ export class ScheduleService { startDate, endDate, JSON.stringify(scheduleData.paymentConfig), + scheduleData.timezone, nextRunTimestamp, 'active', ]; @@ -132,7 +160,7 @@ export class ScheduleService { await client.query('COMMIT'); const schedule = result.rows[0]; - + // Parse dates and JSON from database return { ...schedule, @@ -174,7 +202,7 @@ export class ScheduleService { const startDate = new Date(scheduleData.startDate); const today = new Date(); today.setHours(0, 0, 0, 0); - + if (startDate < today) { throw new Error('Start date cannot be in the past'); } @@ -192,6 +220,20 @@ export class ScheduleService { throw new Error('Payment configuration is required'); } + // Validate timezone + if (!scheduleData.timezone || scheduleData.timezone.trim() === '') { + throw new Error('Timezone is required'); + } + + try { + DateTime.local().setZone(scheduleData.timezone); + if (!DateTime.local().setZone(scheduleData.timezone).isValid) { + throw new Error('Invalid timezone'); + } + } catch (e) { + throw new Error(`Invalid timezone: ${scheduleData.timezone}`); + } + if (scheduleData.paymentConfig.recipients.length === 0) { throw new Error('At least one recipient is required'); } @@ -240,6 +282,7 @@ export class ScheduleService { start_date as "startDate", end_date as "endDate", payment_config as "paymentConfig", + timezone, next_run_timestamp as "nextRunTimestamp", last_run_timestamp as "lastRunTimestamp", status, @@ -255,7 +298,7 @@ export class ScheduleService { const result = await client.query(query, values); // Parse dates and JSON from database - return result.rows.map((row) => ({ + return result.rows.map((row: any) => ({ ...row, startDate: new Date(row.startDate), endDate: row.endDate ? new Date(row.endDate) : undefined, @@ -283,32 +326,32 @@ export class ScheduleService { FROM schedules WHERE id = $1 `; - + const selectResult = await client.query(selectQuery, [scheduleId]); - + // Check if schedule exists if (selectResult.rows.length === 0) { const error = new Error('Schedule not found') as any; error.statusCode = 404; throw error; } - + const schedule = selectResult.rows[0]; - + // Verify schedule belongs to the organization if (schedule.organizationId !== organizationId) { const error = new Error('Access denied: Schedule belongs to a different organization') as any; error.statusCode = 403; throw error; } - + // Update schedule status to 'cancelled' const updateQuery = ` UPDATE schedules SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = $1 `; - + await client.query(updateQuery, [scheduleId]); } finally { client.release(); @@ -330,24 +373,25 @@ export class ScheduleService { frequency, time_of_day as "timeOfDay", start_date as "startDate", + timezone, last_run_timestamp as "lastRunTimestamp" FROM schedules WHERE id = $1 `; - + const selectResult = await client.query(selectQuery, [scheduleId]); - + if (selectResult.rows.length === 0) { throw new Error(`Schedule with ID ${scheduleId} not found`); } - + const schedule = selectResult.rows[0]; const executionTime = new Date(); - + // Determine the new status and next_run_timestamp based on execution result let newStatus: string; let nextRunTimestamp: Date | null = null; - + if (!executionResult.success) { // If execution failed, set status to 'failed' newStatus = 'failed'; @@ -363,11 +407,12 @@ export class ScheduleService { schedule.frequency, schedule.timeOfDay, new Date(schedule.startDate), + schedule.timezone, executionTime, // Use execution time as lastRun ); } } - + // Update the schedule in the database const updateQuery = ` UPDATE schedules @@ -378,14 +423,14 @@ export class ScheduleService { updated_at = CURRENT_TIMESTAMP WHERE id = $4 `; - + await client.query(updateQuery, [ executionTime, newStatus, nextRunTimestamp, scheduleId, ]); - + await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); diff --git a/backend/src/services/transactionAuditService.ts b/backend/src/services/transactionAuditService.ts index 386c3539..82bf96c3 100644 --- a/backend/src/services/transactionAuditService.ts +++ b/backend/src/services/transactionAuditService.ts @@ -1,5 +1,5 @@ import { StellarService } from './stellarService.js'; -import pool from '../config/database.js'; +import { pool } from '../config/database.js'; export interface AuditRecord { id: number; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2ac21981..fa35ad06 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,43 +1,38 @@ { - // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { - // File Layout - "rootDir": "./src" /* Specify the root folder within your source files. */, - "outDir": "./dist" /* Specify an output folder for all emitted files. */, - // Environment Settings - // See also https://aka.ms/tsconfig/module - "module": "NodeNext" /* Specify what module code is generated. */, - "moduleResolution": "NodeNext" /* Specify how TypeScript looks up a file from a given module specifier. */, - "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "types": [], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - // Other Outputs - "sourceMap": true, + "rootDir": "./src", + "outDir": "./dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "lib": [ + "es2022" + ], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, "declaration": true, "declarationMap": true, - // Stricter Typechecking Options + "sourceMap": true, + "types": [ + "node", + "jest" + ], "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - // Style Options - // "noImplicitReturns": true, - // "noImplicitOverride": true, - // "noUnusedLocals": true, - // "noUnusedParameters": true, - // "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - // Recommended Options - "strict": true /* Enable all strict type-checking options. */, "jsx": "react-jsx", - "verbatimModuleSyntax": true, - "isolatedModules": true, - "noUncheckedSideEffectImports": true, - "moduleDetection": "force", - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true + "verbatimModuleSyntax": false, + "isolatedModules": false }, - "include": ["src/**/*"] -} + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src_backup", + "**/*.test.ts", + "src/benchmarks/**/*" + ] +} \ No newline at end of file diff --git a/frontend/src/hooks/useWallet.ts b/frontend/src/hooks/useWallet.ts index 55bc9333..793599f7 100644 --- a/frontend/src/hooks/useWallet.ts +++ b/frontend/src/hooks/useWallet.ts @@ -4,7 +4,8 @@ export interface WalletContextType { address: string | null; walletName: string | null; isConnecting: boolean; - isExtensionAvailable: boolean; + isInitialized: boolean; + walletExtensionAvailable: boolean; connect: () => Promise; disconnect: () => void; signTransaction: (xdr: string) => Promise; diff --git a/frontend/src/hooks/useWalletSigning.ts b/frontend/src/hooks/useWalletSigning.ts index 4660abd7..29b2709e 100644 --- a/frontend/src/hooks/useWalletSigning.ts +++ b/frontend/src/hooks/useWalletSigning.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useWallet } from './useWallet'; +import { useWallet } from './useWallet.js'; +import { useNotification } from './useNotification.js'; /** * Convenience hook for signing Stellar transactions via the connected wallet. @@ -10,7 +11,8 @@ import { useWallet } from './useWallet'; * const signedXdr = await sign(transactionXdr); */ export function useWalletSigning() { - const { signTransaction, address, requireWallet } = useWallet(); + const { signTransaction, address, requireWallet, isConnecting } = useWallet(); + const { notifyError } = useNotification(); const [isSigning, setIsSigning] = useState(false); const [error, setError] = useState(null); @@ -18,16 +20,18 @@ export function useWalletSigning() { setIsSigning(true); setError(null); try { + // Use the callback version of requireWallet const signedXdr = await requireWallet(() => signTransaction(xdr)); return signedXdr; } catch (e) { const message = e instanceof Error ? e.message : 'Signing failed'; setError(message); + notifyError('Signing failed', message); throw e; } finally { setIsSigning(false); } }; - return { sign, isSigning, error, isReady: !!address }; + return { sign, isSigning, error, isReady: !!address && !isConnecting }; } diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index 4f225844..c7dbd6cd 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -1,124 +1,147 @@ -import { useState, useEffect } from 'react'; -import { pathfindingService, PathRecord } from '../services/pathfinding'; -import { Loader2, ArrowRightLeft, ShieldCheck, Info, CheckCircle2, Wallet } from 'lucide-react'; -import { useNotification } from '../hooks/useNotification'; -import { useWallet } from '../hooks/useWallet'; +import { useEffect, useMemo, useState } from 'react'; +import { Loader2, ArrowRightLeft, ShieldCheck, Info, CheckCircle2, Radio, Wallet } from 'lucide-react'; +import { useNotification } from '../hooks/useNotification.js'; +import { useSocket } from '../hooks/useSocket.js'; +import { useWallet } from '../hooks/useWallet.js'; +import { useWalletSigning } from '../hooks/useWalletSigning.js'; +import { contractService } from '../services/contracts.js'; import { - TransactionBuilder, - Networks, - Contract, - nativeToScVal, - Account, -} from '@stellar/stellar-sdk'; + fetchConversionPaths, + submitCrossAssetPayment, + type ConversionPath, +} from '../services/crossAssetPayment.js'; export default function CrossAssetPayment() { const { notifySuccess, notifyError } = useNotification(); - const { address, signTransaction, requireWallet } = useWallet(); + const { socket } = useSocket(); + const { address, connect } = useWallet(); + const { sign } = useWalletSigning(); + const [assetIn, setAssetIn] = useState('USDC'); const [assetOut, setAssetOut] = useState('XLM'); const [amount, setAmount] = useState(''); const [receiver, setReceiver] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [paths, setPaths] = useState([]); - // Use paths console debug to prevent unused lint - console.debug('Found paths:', paths); - const [selectedPath, setSelectedPath] = useState(null); + const [paths, setPaths] = useState([]); + const [selectedPathId, setSelectedPathId] = useState(''); + const [isLoadingPaths, setIsLoadingPaths] = useState(false); + const [submissionTxHash, setSubmissionTxHash] = useState(null); + const [liveStatusMessage, setLiveStatusMessage] = useState('Waiting for submission...'); + const [status, setStatus] = useState('idle'); - const [status, setStatus] = useState<'idle' | 'initiating' | 'pending' | 'completed' | 'error'>( - 'idle' + const selectedPath = useMemo( + () => paths.find((path) => path.id === selectedPathId) || null, + [paths, selectedPathId] ); - const [txId, setTxId] = useState(null); - // Debounced pathfinding fetch useEffect(() => { - const fetchPaths = async () => { - if (!amount || Number(amount) <= 0) { - setPaths([]); - setSelectedPath(null); - return; - } - setIsLoading(true); - try { - // We assume testnet issuers for this demo - const sourceAssetInput = - assetIn === 'USDC' - ? 'USDC:GBBD47IF6LWK7P7MDEVSCWTTCJM4TI9JMKIGYJAYZ6UUKUXXVXYHYRXP' - : assetIn; - const destAssetInput = - assetOut === 'USDC' - ? 'USDC:GBBD47IF6LWK7P7MDEVSCWTTCJM4TI9JMKIGYJAYZ6UUKUXXVXYHYRXP' - : assetOut; + const parsedAmount = Number.parseFloat(amount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + setPaths([]); + setSelectedPathId(''); + return; + } - const results = await pathfindingService.fetchCrossAssetPaths( - sourceAssetInput, - amount, - destAssetInput - ); - setPaths(results); - setSelectedPath(results.length > 0 ? results[0] : null); - } catch (error) { - console.error(error); - notifyError('Pathfinding failed', 'Could not retrieve conversion routes from the network.'); - } finally { - setIsLoading(false); - } + setIsLoadingPaths(true); + const timeout = setTimeout(() => { + void (async () => { + try { + const nextPaths = await fetchConversionPaths({ + fromAsset: assetIn, + toAsset: assetOut, + amount: parsedAmount, + }); + setPaths(nextPaths); + setSelectedPathId((current) => current || nextPaths[0]?.id || ''); + } catch (error) { + notifyError( + 'Pathfinding failed', + error instanceof Error ? error.message : 'Failed to fetch conversion paths.' + ); + } finally { + setIsLoadingPaths(false); + } + })(); + }, 450); + + return () => { + clearTimeout(timeout); + setIsLoadingPaths(false); }; + }, [amount, assetIn, assetOut, notifyError]); - const timerId = setTimeout(() => { - void fetchPaths(); - }, 600); // 600ms debounce + useEffect(() => { + if (!socket || !submissionTxHash) return; - return () => clearTimeout(timerId); - }, [amount, assetIn, assetOut, notifyError]); + const handler = (payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const record = payload as Record; + const txHash = (record.txHash as string | undefined) || (record.hash as string | undefined); + if (!txHash || txHash !== submissionTxHash) return; - const handleInitiate = async () => { - setStatus('initiating'); - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const envContractId = import.meta.env.VITE_CROSS_ASSET_PAYMENT_CONTRACT_ID; - const contractId = - (envContractId as string) || 'CBRZZW3D52HFW57TDFVRYC6NYL33N23S4VDKF27I46445G3UKWJMFPBM'; - const contract = new Contract(contractId); + const nextStatus = + (record.status as string | undefined) || + (record.state as string | undefined) || + 'processing'; + setStatus(nextStatus); + setLiveStatusMessage(`Live update: ${nextStatus}`); + if (nextStatus === 'completed' || nextStatus === 'confirmed') { + notifySuccess('Cross-asset payment completed', `Transaction ${txHash} settled.`); + } + }; - // We create a mock Soroban invocation for the swap function - const invokeOp = contract.call( - 'swap', - nativeToScVal(address!, { type: 'address' }), - nativeToScVal(receiver, { type: 'address' }), - nativeToScVal(Math.floor(Number(amount) * 1e7), { type: 'i128' }) // Mapped Amount - ); + socket.on('cross-asset:update', handler); + socket.on('transaction:update', handler); + socket.emit('subscribe:transaction', submissionTxHash); - // Dummy account since we just want to build a payload to sign in this mock demo - const account = new Account(address!, '0'); + return () => { + socket.off('cross-asset:update', handler); + socket.off('transaction:update', handler); + socket.emit('unsubscribe:transaction', submissionTxHash); + }; + }, [notifySuccess, socket, submissionTxHash]); - const transaction = new TransactionBuilder(account, { - fee: '10000', - networkPassphrase: Networks.TESTNET, - }) - .addOperation(invokeOp) - .setTimeout(30) - .build(); + const handleInitiate = async () => { + if (!address) { + notifyError('Wallet required', 'Connect your wallet before submitting cross-asset payment.'); + return; + } + if (!selectedPath) { + notifyError('No path selected', 'Select a conversion path before submitting.'); + return; + } - const xdrString = transaction.toXDR(); + const parsedAmount = Number.parseFloat(amount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + notifyError('Invalid amount', 'Enter a valid payment amount.'); + return; + } - notifySuccess( - 'Please Sign', - 'Prompting your wallet to sign the Cross-Asset implementation...' - ); + setStatus('submitting'); + try { + await contractService.initialize(); + const contractId = + contractService.getContractId('cross_asset_payment', 'testnet') || + (import.meta.env.VITE_CROSS_ASSET_PAYMENT_CONTRACT_ID as string | undefined); + if (!contractId) { + throw new Error('Cross-asset contract ID is unavailable.'); + } - // Wallet Signature Call with Guard - await requireWallet(() => signTransaction(xdrString)); - setTxId('simulated_tx_hash_' + Date.now()); + const result = await submitCrossAssetPayment({ + contractId, + sourceAddress: address, + signTransaction: sign, + amount: parsedAmount, + fromAsset: assetIn, + toAsset: assetOut, + receiver, + selectedPathId: selectedPath.id, + }); + setSubmissionTxHash(result.txHash); setStatus('pending'); - - // Since it's a signed blob, we'd normally submit it to Horizon / Soroban RPC here. - // We simulate waiting for ledger settlement - setTimeout(() => { - setStatus('completed'); - notifySuccess('Payment completed!', `${amount} ${assetIn} cross-asset payment succeeded.`); - }, 3000); + setLiveStatusMessage('Submitted. Waiting for live settlement updates...'); + notifySuccess('Payment submitted', `On-chain transaction hash: ${result.txHash}`); } catch (error) { console.error(error); setStatus('error'); @@ -131,24 +154,36 @@ export default function CrossAssetPayment() { } }; - const currentRate = selectedPath - ? (Number(selectedPath.destination_amount) / Number(selectedPath.source_amount)).toFixed(4) - : '0'; - return (
-
-

- Soroban Cross-Asset Swap -

-

- Seamlessly pay anyone in their preferred asset utilizing on-chain liquidity pools. -

+
+
+

+ Cross-Asset Payment Settlement +

+

+ Live pathfinding, Soroban simulation, and wallet-signed contract submission. +

+
+ {!address ? ( + + ) : ( + + {address.slice(0, 6)}...{address.slice(-4)} + + )}
- {/* Payment Form */}
@@ -179,6 +214,10 @@ export default function CrossAssetPayment() { > + + + +
@@ -209,8 +248,8 @@ export default function CrossAssetPayment() { type="text" value={receiver} onChange={(e) => setReceiver(e.target.value)} - placeholder="G..." - className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-xl px-4 py-3 outline-none overflow-hidden text-ellipsis whitespace-nowrap" + placeholder="G... recipient wallet" + className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-xl px-4 py-3 outline-none" />
@@ -218,60 +257,90 @@ export default function CrossAssetPayment() { onClick={() => { void handleInitiate(); }} - disabled={ - status === 'initiating' || status === 'pending' || (!!address && !selectedPath) - } - className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 py-4 rounded-xl font-bold text-lg hover:opacity-90 transition-all disabled:opacity-50 flex items-center justify-center gap-2" + disabled={status === 'submitting' || status === 'pending' || !selectedPath} + className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 py-4 rounded-xl font-bold text-lg hover:opacity-90 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - {status === 'initiating' ? ( + {status === 'submitting' ? ( ) : !address ? ( <> Connect Wallet to Swap ) : ( - 'Sign & Swap via Contract' + 'Simulate + Submit Payment' )}
- {/* Right Column: Info & Status */}
- {/* Quote Panel */} - {selectedPath && ( + {(isLoadingPaths || paths.length > 0) && (

- Live Pathfinding Route + Available Conversion Paths

-
+ {isLoadingPaths ? ( +
+ + Fetching conversion paths... +
+ ) : ( +
+ {paths.map((path) => ( + + ))} +
+ )} +
+ )} + + {selectedPath && ( +
+

Settlement Preview

+
- Effective Rate + Expected Delivery - 1 {assetIn} ≈ {currentRate} {assetOut} + {selectedPath.estimatedDestinationAmount.toLocaleString()} {assetOut}
- Path Hops - {selectedPath.path.length} hops -
-
- Guaranteed Destination - - {Number(selectedPath.destination_amount).toLocaleString()} {assetOut} + Fee + + {selectedPath.fee.toFixed(4)} {assetOut}
+
+ Slippage + {selectedPath.slippage.toFixed(2)}% +
)} - {/* Status Panel */} {status !== 'idle' && (
{status}
@@ -286,14 +355,14 @@ export default function CrossAssetPayment() {
-

Wallet Authentication

-

Transaction Signed Successfully

+

Authentication

+

Wallet connected and signer ready

{status === 'pending' ? ( @@ -302,48 +371,43 @@ export default function CrossAssetPayment() { )}
-

Contract Execution

-

Soroban cross_asset_payment Invoked

+

Initiation

+

Contract call simulated and submitted

Settlement

-

Network Consensus Reached

+

{liveStatusMessage}

- {txId && ( + {submissionTxHash && (
-

Transaction ID

-

{txId}

+

+ Transaction Hash +

+

{submissionTxHash}

)}
)} - {!selectedPath && !isLoading && ( + {!selectedPath && !isLoadingPaths && (

- Enter an amount and receiver to query the network for the best cross-asset - liquidity paths automatically. + Change asset pair and amount to request path options from backend proxy.

)} - - {isLoading && ( -
- -
- )}
diff --git a/frontend/src/pages/TransactionHistory.tsx b/frontend/src/pages/TransactionHistory.tsx index 33742d26..a4a3a918 100644 --- a/frontend/src/pages/TransactionHistory.tsx +++ b/frontend/src/pages/TransactionHistory.tsx @@ -1,232 +1,112 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Activity, Calendar, Filter, Search } from 'lucide-react'; import { - Search, - Filter, - Calendar, - X, - Activity, - User, - Tag, - Loader2, - Cpu, - CheckCircle, -} from 'lucide-react'; -import { - fetchAuditLogs, - AuditRecord, - AuditListFilters, - fetchEmployees, - Employee, -} from '../services/auditApi'; + fetchHistoryPage, + type HistoryFilters, + type TimelineItem, +} from '../services/transactionHistory.js'; -const ASSETS = ['USDC', 'XLM', 'NGN']; -const STATUSES = ['Completed', 'Pending', 'Failed']; +const DEFAULT_FILTERS: HistoryFilters = { + search: '', + status: '', + employee: '', + asset: '', + startDate: '', + endDate: '', +}; -// Hook for debouncing fast state updates -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - return () => clearTimeout(handler); - }, [value, delay]); - return debouncedValue; +function getStatusClass(status: string): string { + if (status === 'confirmed' || status === 'indexed') { + return 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'; + } + if (status === 'pending') { + return 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'; + } + return 'bg-red-500/10 text-red-400 border border-red-500/20'; } -function SkeletonRow() { + +function TimelineSkeleton() { return ( - - -
- - -
- - -
- - -
- - -
- - -
- - +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+
+
+
+
+ ))} +
); } export default function TransactionHistory() { - const [searchTerm, setSearchTerm] = useState(''); - const [selectedAssets, setSelectedAssets] = useState([]); - const [selectedStatuses, setSelectedStatuses] = useState([]); - const [selectedEmployees, setSelectedEmployees] = useState([]); - const [dateRange, setDateRange] = useState({ start: '', end: '' }); - const [isFilterExpanded, setIsFilterExpanded] = useState(false); - const [employeesList, setEmployeesList] = useState([]); - - useEffect(() => { - const loadEmployees = async () => { - const res = await fetchEmployees(); - setEmployeesList(res.data || []); - }; - void loadEmployees(); - }, []); - - // API State - const [transactions, setTransactions] = useState([]); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [debouncedFilters, setDebouncedFilters] = useState(DEFAULT_FILTERS); + const [items, setItems] = useState([]); const [page, setPage] = useState(1); - const [isLoading, setIsLoading] = useState(true); const [hasMore, setHasMore] = useState(false); - const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [showFilters, setShowFilters] = useState(false); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedFilters(filters); + setPage(1); + }, 350); - // Debounced filters - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const debouncedDateRange = useDebounce(dateRange, 500); - const LIMIT = 20; + return () => { + clearTimeout(timeout); + }; + }, [filters]); - // Load Data Effect - const loadData = useCallback( - async (isLoadMore: boolean = false) => { + useEffect(() => { + const load = async () => { setIsLoading(true); + setError(null); try { - const filters: AuditListFilters = { - page: isLoadMore ? page + 1 : 1, - limit: LIMIT, - sourceAccount: debouncedSearchTerm.length === 56 ? debouncedSearchTerm : undefined, - dateStart: debouncedDateRange.start || undefined, - dateEnd: debouncedDateRange.end || undefined, - employeeId: selectedEmployees.length === 1 ? selectedEmployees[0] : undefined, - asset: selectedAssets.length === 1 ? selectedAssets[0] : undefined, - status: selectedStatuses.length === 1 ? selectedStatuses[0] : undefined, - }; - - const res = await fetchAuditLogs(filters); - - if (isLoadMore) { - setTransactions((prev: AuditRecord[]) => [...prev, ...res.data]); - setPage(res.page); - } else { - setTransactions(res.data); - setPage(1); - } - - setTotalCount(res.total); - setHasMore(res.page < res.totalPages); - } catch (e) { - console.error('Failed to load transactions', e); + const result = await fetchHistoryPage({ + page: 1, + limit: 20, + filters: debouncedFilters, + }); + setItems(result.items); + setHasMore(result.hasMore); + } catch (loadError) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load transaction history' + ); } finally { setIsLoading(false); } - }, - [ - page, - debouncedSearchTerm, - debouncedDateRange, - selectedEmployees, - selectedAssets, - selectedStatuses, - ] - ); + }; - useEffect(() => { - void loadData(false); - }, [ - debouncedSearchTerm, - debouncedDateRange, - selectedEmployees, - selectedAssets, - selectedStatuses, - loadData, - ]); + void load(); + }, [debouncedFilters]); - const handleLoadMore = () => { - if (!isLoading && hasMore) { - void loadData(true); - } - }; + const activeFilterCount = useMemo( + () => (Object.values(filters) as string[]).filter((value) => value.trim().length > 0).length, + [filters] + ); - // Active filters array for tags - const activeFilters = useMemo(() => { - const filters: { type: string; value: string; label: string }[] = []; - if (searchTerm) - filters.push({ type: 'search', value: searchTerm, label: `Search: ${searchTerm}` }); - selectedAssets.forEach((a: string) => - filters.push({ type: 'asset', value: a, label: `Asset: ${a}` }) - ); - selectedStatuses.forEach((s: string) => - filters.push({ type: 'status', value: s, label: `Status: ${s}` }) - ); - selectedEmployees.forEach((eId: string) => { - const emp = employeesList.find((emp: Employee) => emp.id.toString() === eId); - const name = emp ? `${emp.first_name} ${emp.last_name}` : `Emp #${eId}`; - filters.push({ type: 'employee', value: eId, label: `Employee: ${name}` }); - }); - if (dateRange.start) - filters.push({ - type: 'dateStart', - value: dateRange.start, - label: `From: ${dateRange.start}`, + const loadMore = async () => { + const nextPage = page + 1; + setIsLoadingMore(true); + try { + const result = await fetchHistoryPage({ + page: nextPage, + limit: 20, + filters: debouncedFilters, }); - if (dateRange.end) - filters.push({ type: 'dateEnd', value: dateRange.end, label: `To: ${dateRange.end}` }); - return filters; - }, [searchTerm, selectedAssets, selectedStatuses, selectedEmployees, dateRange, employeesList]); - - const removeFilter = (filter: { type: string; value: string }) => { - switch (filter.type) { - case 'search': - setSearchTerm(''); - break; - case 'asset': - setSelectedAssets((prev: string[]) => prev.filter((a: string) => a !== filter.value)); - break; - case 'status': - setSelectedStatuses((prev: string[]) => prev.filter((s: string) => s !== filter.value)); - break; - case 'employee': - setSelectedEmployees((prev: string[]) => prev.filter((e: string) => e !== filter.value)); - break; - case 'dateStart': - setDateRange((prev: { start: string; end: string }) => ({ ...prev, start: '' })); - break; - case 'dateEnd': - setDateRange((prev: { start: string; end: string }) => ({ ...prev, end: '' })); - break; + setItems((prev) => [...prev, ...result.items]); + setPage(nextPage); + setHasMore(result.hasMore); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load more history'); + } finally { + setIsLoadingMore(false); } - setPage(1); - }; - - const clearAllFilters = () => { - setSearchTerm(''); - setSelectedAssets([]); - setSelectedStatuses([]); - setSelectedEmployees([]); - setDateRange({ start: '', end: '' }); - setPage(1); - }; - - const toggleAsset = (asset: string) => { - setSelectedAssets((prev: string[]) => - prev.includes(asset) ? prev.filter((a: string) => a !== asset) : [...prev, asset] - ); - setPage(1); - }; - - const toggleStatus = (status: string) => { - setSelectedStatuses((prev: string[]) => - prev.includes(status) ? prev.filter((s: string) => s !== status) : [...prev, status] - ); - setPage(1); - }; - - const toggleEmployee = (empId: string) => { - setSelectedEmployees((prev: string[]) => - prev.includes(empId) ? prev.filter((e: string) => e !== empId) : [...prev, empId] - ); - setPage(1); }; return ( @@ -237,318 +117,145 @@ export default function TransactionHistory() { Transaction History

- Track and filter all organizational transfers + Unified classic + contract event timeline

- {/* Expanded Filter Header */} - {isFilterExpanded && ( -
-
- {/* Search */} -
- -
- - setSearchTerm(e.target.value)} - className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2.5 pl-10 pr-4 text-sm focus:ring-1 focus:ring-accent outline-none transition-all" - /> -
+ {showFilters && ( +
+
+
+ + + setFilters((prev) => ({ ...prev, search: event.target.value })) + } + placeholder="Search tx hash / actor" + className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2.5 pl-10 pr-4 text-sm" + />
- {/* Date Range */} -
- -
-
- - - setDateRange((prev: { start: string; end: string }) => ({ - ...prev, - start: e.target.value, - })) - } - className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2 pl-8 pr-2 text-xs focus:ring-1 focus:ring-accent outline-none text-zinc-300 custom-date-input" - /> -
- - -
- - - setDateRange((prev: { start: string; end: string }) => ({ - ...prev, - end: e.target.value, - })) - } - className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2 pl-8 pr-2 text-xs focus:ring-1 focus:ring-accent outline-none text-zinc-300 custom-date-input" - /> -
-
-
+ - {/* Assets Multi-select */} -
- -
- {ASSETS.map((asset) => ( - - ))} -
-
+ + setFilters((prev) => ({ ...prev, employee: event.target.value })) + } + placeholder="Employee" + className="bg-[#0a0a0c] border border-zinc-800 rounded-lg px-3 py-2.5 text-sm" + /> - {/* Statuses Multi-select */} -
- -
- {STATUSES.map((status) => ( - - ))} -
+ setFilters((prev) => ({ ...prev, asset: event.target.value }))} + placeholder="Asset (USDC, XLM...)" + className="bg-[#0a0a0c] border border-zinc-800 rounded-lg px-3 py-2.5 text-sm" + /> + +
+ + + setFilters((prev) => ({ ...prev, startDate: event.target.value })) + } + className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2.5 pl-10 pr-4 text-sm" + />
- {/* Employees Multi-select (Dynamic from DB) */} -
- -
- {employeesList.map((emp: Employee) => ( - - ))} -
+
+ + + setFilters((prev) => ({ ...prev, endDate: event.target.value })) + } + className="w-full bg-[#0a0a0c] border border-zinc-800 rounded-lg py-2.5 pl-10 pr-4 text-sm" + />
)} - {/* Active Filters Bar */} - {activeFilters.length > 0 && ( -
- - Active Filters: - - {activeFilters.map((filter: { type: string; value: string; label: string }) => ( - - {filter.label} - - - ))} - -
- )} +
+ {error ?

{error}

: null} + {isLoading ? : null} + + {!isLoading && items.length === 0 ? ( +
+ +

No records found for current filters.

+
+ ) : null} - {/* Results Table */} -
-
- - - - - - - - - - - - - {isLoading && transactions.length === 0 ? ( - <> - - - - - - - ) : transactions.length > 0 ? ( - transactions.map((txn: AuditRecord, idx: number) => ( - 0 ? ( +
+ {items.map((item) => ( +
+
+ + {item.badge} + + -
- - - - - - - )) - ) : ( - - - - )} - -
- Txn ID - - Date - - Employee - - Asset - - Amount - - Status -
- {txn.tx_hash.substring(0, 12)}... - {txn.is_contract_event && ( - - Event - - )} - - {new Date(txn.created_at).toLocaleDateString()} - - {txn.employee_name || 'System / N/A'} - - - {txn.asset || 'NATIVE'} - - - {txn.amount || '0'}{' '} - {txn.asset || 'XLM'} - - - {txn.status || 'Pending'} - -
-
- -

No transactions match the selected filters.

- -
-
-
+ {item.status} + + + {new Date(item.createdAt).toLocaleString()} + +
+

{item.label}

+

Actor: {item.actor}

+

+ Amount: {item.amount} {item.asset} +

+ {item.txHash ? ( +

{item.txHash}

+ ) : null} +
+ ))} +
+ ) : null} - {/* Load More Button */} - {hasMore && ( -
+ {!isLoading && hasMore ? ( +
- )} -
-
- - Showing {transactions.length} of {totalCount} transactions - - Filter engine active + ) : null}
- - {/* Custom Scrollbar & Utility Styles for this page */} -
); } diff --git a/frontend/src/providers/WalletProvider.tsx b/frontend/src/providers/WalletProvider.tsx index fa5b0024..9a3a5c9c 100644 --- a/frontend/src/providers/WalletProvider.tsx +++ b/frontend/src/providers/WalletProvider.tsx @@ -10,52 +10,71 @@ import { useTranslation } from 'react-i18next'; import { useNotification } from '../hooks/useNotification'; import { WalletContext } from '../hooks/useWallet'; +const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name'; + +function hasAnyWalletExtension(): boolean { + if (typeof window === 'undefined') return true; + const extendedWindow = window as Window & + typeof globalThis & { + freighterApi?: unknown; + xBullSDK?: unknown; + lobstr?: unknown; + }; + + return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr); +} + export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [address, setAddress] = useState(null); const [walletName, setWalletName] = useState(null); const [isConnecting, setIsConnecting] = useState(false); - const [isExtensionAvailable, setIsExtensionAvailable] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + const [walletExtensionAvailable, setWalletExtensionAvailable] = useState(true); + const kitRef = useRef(null); const { t } = useTranslation(); const { notify, notifySuccess, notifyError } = useNotification(); - const STORAGE_KEY = 'payd_last_wallet'; - useEffect(() => { + setWalletExtensionAvailable(hasAnyWalletExtension()); + const newKit = new StellarWalletsKit({ network: WalletNetwork.TESTNET, modules: [new FreighterModule(), new xBullModule(), new LobstrModule()], }); kitRef.current = newKit; - // Check if Freighter is available (basic check) - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - if (typeof window !== 'undefined' && !(window as any).freighter) { - setIsExtensionAvailable(false); - } + const attemptSilentReconnect = async () => { + const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY); + if (!lastWalletName) { + setIsInitialized(true); + return; + } - // Silent reconnection - const lastWallet = localStorage.getItem(STORAGE_KEY); - if (lastWallet) { - void (async () => { - setIsConnecting(true); - try { - // In stellar-wallets-kit, you usually need to set the wallet first - newKit.setWallet(lastWallet); - const { address } = await newKit.getAddress(); - if (address) { - setAddress(address); - setWalletName(lastWallet); - } - } catch (error) { - console.warn('Silent reconnection failed:', error); - localStorage.removeItem(STORAGE_KEY); - } finally { - setIsConnecting(false); + setWalletName(lastWalletName); + setIsConnecting(true); + + try { + newKit.setWallet(lastWalletName); + const account = await newKit.getAddress(); + if (account?.address) { + setAddress(account.address); + notifySuccess( + 'Wallet reconnected', + `${account.address.slice(0, 6)}...${account.address.slice(-4)} via ${lastWalletName}` + ); } - })(); - } - }, []); + } catch (error) { + console.warn('Silent reconnection failed:', error); + localStorage.removeItem(LAST_WALLET_STORAGE_KEY); + } finally { + setIsConnecting(false); + setIsInitialized(true); + } + }; + + void attemptSilentReconnect(); + }, [notifySuccess]); const connect = async () => { const kit = kitRef.current; @@ -71,7 +90,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr const { address } = await kit.getAddress(); setAddress(address); setWalletName(option.id); - localStorage.setItem(STORAGE_KEY, option.id); + localStorage.setItem(LAST_WALLET_STORAGE_KEY, option.id); notifySuccess( 'Wallet connected', `${address.slice(0, 6)}...${address.slice(-4)} via ${option.id}` @@ -98,7 +117,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr const disconnect = () => { setAddress(null); setWalletName(null); - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(LAST_WALLET_STORAGE_KEY); notify('Wallet disconnected'); }; @@ -111,8 +130,6 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr // Check again after modal interaction if (!address) { - // If still no address, it likely means the user closed the modal or failed - // For the sake of UX, we should probably throw an error that the caller can catch throw new Error('Wallet connection required to perform this action'); } @@ -127,19 +144,32 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr }; return ( - - {children} - + <> + {!walletExtensionAvailable && ( +
+ Wallet extension not detected. Install Freighter, xBull, or Lobstr to sign transactions. +
+ )} + + + {isInitialized ? ( + children + ) : ( +
Restoring wallet session...
+ )} +
+ ); }; diff --git a/package.json b/package.json index f85d647a..f013f641 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "zod": "^4.1.13" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^10.0.1", "@types/lodash": "^4.17.21", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -37,21 +37,21 @@ "@vitejs/plugin-react": "^5.1.1", "concurrently": "^9.2.1", "dotenv": "^17.2.3", - "eslint": "^9.39.1", + "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-dom": "^2.3.11", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-x": "^2.3.11", "glob": "^13.0.0", "globals": "^16.5.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", - "prettier": "3.6.2", + "prettier": "3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.1", "vite": "^7.2.6", - "vite-plugin-node-polyfills": "^0.24.0", + "vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-wasm": "^3.5.0" }, "lint-staged": { @@ -59,6 +59,5 @@ "eslint --fix --no-warn-ignored", "prettier --write --ignore-unknown" ] - }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + } } From 1ee446a3dd0aeb35c1b6fa6d07fda86716f66bb9 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Sun, 8 Mar 2026 12:53:26 +0100 Subject: [PATCH 2/4] fix(frontend): resolve strict lint failures and unsafe type operations --- backend/src/types/schedule.ts | 4 + frontend/src/pages/CrossAssetPayment.tsx | 16 +- frontend/src/pages/RevenueSplitDashboard.tsx | 395 +++++++++++++++++++ frontend/src/pages/TransactionHistory.tsx | 13 +- frontend/src/services/bulkPaymentStatus.ts | 145 +++++++ frontend/src/services/crossAssetPayment.ts | 146 +++++++ frontend/src/services/revenueSplit.ts | 210 ++++++++++ frontend/src/services/transactionHistory.ts | 137 +++++++ 8 files changed, 1057 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/RevenueSplitDashboard.tsx create mode 100644 frontend/src/services/bulkPaymentStatus.ts create mode 100644 frontend/src/services/crossAssetPayment.ts create mode 100644 frontend/src/services/revenueSplit.ts create mode 100644 frontend/src/services/transactionHistory.ts diff --git a/backend/src/types/schedule.ts b/backend/src/types/schedule.ts index 652db160..e35ebe13 100644 --- a/backend/src/types/schedule.ts +++ b/backend/src/types/schedule.ts @@ -32,6 +32,7 @@ export interface Schedule { startDate: Date; endDate?: Date; paymentConfig: PaymentConfig; + timezone: string; nextRunTimestamp: Date; lastRunTimestamp?: Date; status: ScheduleStatus; @@ -101,6 +102,7 @@ export interface CreateScheduleRequest { timeOfDay: string; // HH:MM format startDate: string; // ISO date endDate?: string; // ISO date, optional for recurring + timezone: string; paymentConfig: PaymentConfig; } @@ -111,6 +113,7 @@ export interface CreateScheduleResponse { timeOfDay: string; startDate: string; endDate?: string; + timezone: string; nextRunTimestamp: string; // ISO timestamp status: string; createdAt: string; @@ -127,6 +130,7 @@ export interface GetSchedulesResponse { nextRunTimestamp: string; lastRunTimestamp?: string; status: string; + timezone: string; paymentConfig: PaymentConfig; createdAt: string; }>; diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index c7dbd6cd..8f8d1348 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -1,5 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; -import { Loader2, ArrowRightLeft, ShieldCheck, Info, CheckCircle2, Radio, Wallet } from 'lucide-react'; +import { + Loader2, + ArrowRightLeft, + ShieldCheck, + Info, + CheckCircle2, + Radio, + Wallet, +} from 'lucide-react'; import { useNotification } from '../hooks/useNotification.js'; import { useSocket } from '../hooks/useSocket.js'; import { useWallet } from '../hooks/useWallet.js'; @@ -29,7 +37,7 @@ export default function CrossAssetPayment() { const [liveStatusMessage, setLiveStatusMessage] = useState('Waiting for submission...'); const [status, setStatus] = useState('idle'); - const selectedPath = useMemo( + const selectedPath = useMemo( () => paths.find((path) => path.id === selectedPathId) || null, [paths, selectedPathId] ); @@ -46,7 +54,7 @@ export default function CrossAssetPayment() { const timeout = setTimeout(() => { void (async () => { try { - const nextPaths = await fetchConversionPaths({ + const nextPaths: ConversionPath[] = await fetchConversionPaths({ fromAsset: assetIn, toAsset: assetOut, amount: parsedAmount, @@ -127,7 +135,7 @@ export default function CrossAssetPayment() { throw new Error('Cross-asset contract ID is unavailable.'); } - const result = await submitCrossAssetPayment({ + const result: { txHash: string } = await submitCrossAssetPayment({ contractId, sourceAddress: address, signTransaction: sign, diff --git a/frontend/src/pages/RevenueSplitDashboard.tsx b/frontend/src/pages/RevenueSplitDashboard.tsx new file mode 100644 index 00000000..3702d140 --- /dev/null +++ b/frontend/src/pages/RevenueSplitDashboard.tsx @@ -0,0 +1,395 @@ +// RevenueSplitDashboard component +import { useEffect, useMemo, useState } from 'react'; +import { useNotification } from '../hooks/useNotification.js'; +import { useWallet } from '../hooks/useWallet.js'; +import { useWalletSigning } from '../hooks/useWalletSigning.js'; +import { contractService } from '../services/contracts.js'; +import { + fetchDistributionEvents, + fetchRevenueSplitAllocations, + updateRevenueAllocations, + type DistributionEvent, + type RevenueAllocation, +} from '../services/revenueSplit.js'; + +const ORGANIZATION_ID = 1; + +function formatAmount(value: number, stablecoin: string): string { + return `${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${stablecoin}`; +} + +function isLikelyStellarAddress(value: string): boolean { + return /^G[A-Z0-9]{55}$/.test(value.trim()); +} + +function buildConicGradient(allocations: RevenueAllocation[]): string { + if (allocations.length === 0) return 'conic-gradient(#3f3f46 0% 100%)'; + const palette = ['#22c55e', '#06b6d4', '#f59e0b', '#a855f7', '#ef4444', '#84cc16']; + let start = 0; + const slices = allocations.map((entry, index) => { + const end = start + entry.percentage; + const segment = `${palette[index % palette.length]} ${start}% ${end}%`; + start = end; + return segment; + }); + return `conic-gradient(${slices.join(',')})`; +} + +export default function RevenueSplitDashboard() { + const [allocations, setAllocations] = useState([]); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const { address, connect } = useWallet(); + const { sign } = useWalletSigning(); + const { notifyError, notifySuccess } = useNotification(); + + const preferredStablecoin: string = ( + (localStorage.getItem('preferredStablecoin') || + import.meta.env.VITE_PREFERRED_STABLECOIN || + 'USDC') as string + ).toUpperCase(); + + const totalAllocation = useMemo( + () => + allocations.reduce( + (sum, entry) => sum + (Number.isFinite(entry.percentage) ? entry.percentage : 0), + 0 + ), + [allocations] + ); + + const recipientBalances = useMemo(() => { + const byRecipient = new Map(); + events.forEach((event) => { + if (!Number.isFinite(event.amount)) return; + byRecipient.set( + event.recipientLabel, + (byRecipient.get(event.recipientLabel) || 0) + event.amount + ); + }); + return Array.from(byRecipient.entries()).map(([recipient, amount]) => ({ + recipient, + amount, + })); + }, [events]); + + const totalDistributed = useMemo( + () => + events.reduce((sum, event) => sum + (Number.isFinite(event.amount) ? event.amount : 0), 0), + [events] + ); + + useEffect(() => { + const loadData = async () => { + setIsLoading(true); + setError(null); + try { + await contractService.initialize(); + const contractId = + contractService.getContractId('revenue_split', 'testnet') || + (import.meta.env.VITE_REVENUE_SPLIT_CONTRACT_ID as string | undefined); + + if (!contractId) { + throw new Error('Revenue split contract ID is unavailable.'); + } + + if (!address) { + setAllocations([]); + } else { + const contractAllocations = await fetchRevenueSplitAllocations(contractId, address); + setAllocations(contractAllocations); + } + + const distributionEvents = await fetchDistributionEvents(ORGANIZATION_ID, 1, 50); + setEvents(distributionEvents); + } catch (loadError) { + const message = loadError instanceof Error ? loadError.message : 'Failed to load dashboard'; + setError(message); + notifyError('Revenue split load failed', message); + } finally { + setIsLoading(false); + } + }; + + void loadData(); + }, [address, notifyError]); + + const setAllocationField = (index: number, field: 'recipient' | 'percentage', value: string) => { + setAllocations((prev) => + prev.map((entry, idx) => + idx === index + ? { + ...entry, + [field]: field === 'percentage' ? Number.parseFloat(value || '0') : value, + } + : entry + ) + ); + }; + + const addRecipient = () => { + setAllocations((prev) => [...prev, { recipient: '', percentage: 0 }]); + }; + + const removeRecipient = (index: number) => { + setAllocations((prev) => prev.filter((_, idx) => idx !== index)); + }; + + const handleSaveAllocations = async () => { + if (!address) { + notifyError('Wallet required', 'Connect a wallet before updating allocations.'); + return; + } + + const hasInvalidAddress = allocations.some((entry) => !isLikelyStellarAddress(entry.recipient)); + if (hasInvalidAddress) { + notifyError( + 'Invalid recipient', + 'Each allocation recipient must be a valid Stellar address.' + ); + return; + } + + if (Math.abs(totalAllocation - 100) > 0.0001) { + notifyError( + 'Invalid allocation total', + `Allocation total must equal 100%. Current total: ${totalAllocation.toFixed(2)}%.` + ); + return; + } + + setIsSaving(true); + try { + await contractService.initialize(); + const contractId = + contractService.getContractId('revenue_split', 'testnet') || + (import.meta.env.VITE_REVENUE_SPLIT_CONTRACT_ID as string | undefined); + if (!contractId) { + throw new Error('Revenue split contract ID is unavailable.'); + } + + const { txHash } = await updateRevenueAllocations({ + contractId, + sourceAddress: address, + allocations, + signTransaction: sign, + }); + + notifySuccess('Allocations updated', `Submitted on-chain update transaction: ${txHash}`); + } catch (saveError) { + const message = + saveError instanceof Error ? saveError.message : 'Failed to update allocations'; + notifyError('Allocation update failed', message); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+

+ Revenue Split Dashboard +

+

+ Contract-backed allocations and distribution history +

+
+ {!address ? ( + + ) : ( + + Connected: {address.slice(0, 6)}...{address.slice(-4)} + + )} +
+ + {isLoading ? ( +

Loading revenue split dashboard...

+ ) : null} + {error ?

{error}

: null} + +
+
+

Current Allocation Splits

+
+
+
+ {allocations.length === 0 ? ( +

No allocation data loaded.

+ ) : ( + allocations.map((entry, idx) => ( + // eslint-disable-next-line react-x/no-array-index-key +

+ {entry.recipient.slice(0, 6)}...{entry.recipient.slice(-4)} -{' '} + {entry.percentage.toFixed(2)}% +

+ )) + )} +

+ Total: {totalAllocation.toFixed(2)}% +

+
+
+
+ +
+
+

Edit Allocations

+ +
+ +
+ {allocations.map((entry, idx) => ( +
+ setAllocationField(idx, 'recipient', event.target.value)} + placeholder="Recipient Stellar Address" + className="md:col-span-8 bg-[#0a0a0c] border border-zinc-800 rounded-lg px-3 py-2 text-xs" + /> + setAllocationField(idx, 'percentage', event.target.value)} + min={0} + max={100} + step={0.01} + placeholder="%" + className="md:col-span-3 bg-[#0a0a0c] border border-zinc-800 rounded-lg px-3 py-2 text-xs" + /> + +
+ ))} +
+ +
+

Total allocation must be exactly 100%.

+ +
+
+
+ +
+
+

Live Recipient Balances

+ {recipientBalances.length === 0 ? ( +

No recipient distributions available yet.

+ ) : ( +
+ {recipientBalances.map((row) => ( +
+ + {row.recipient} + + + {formatAmount(row.amount, preferredStablecoin)} + +
+ ))} +
+ )} +

+ Total Distributed:{' '} + {formatAmount(totalDistributed, preferredStablecoin)} +

+
+ +
+

Historical Distribution Events

+ {events.length === 0 ? ( +

No backend indexed distribution events found.

+ ) : ( +
+ + + + + + + + + + + + {events.map((event) => ( + + + + + + + + ))} + +
DateRecipientActionAmountTx
+ {new Date(event.createdAt).toLocaleString()} + {event.recipientLabel}{event.action} + {formatAmount(event.amount, event.assetCode || preferredStablecoin)} + + {event.txHash ? ( + + {event.txHash.slice(0, 10)}... + + ) : ( + N/A + )} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/TransactionHistory.tsx b/frontend/src/pages/TransactionHistory.tsx index a4a3a918..466d94ef 100644 --- a/frontend/src/pages/TransactionHistory.tsx +++ b/frontend/src/pages/TransactionHistory.tsx @@ -28,8 +28,11 @@ function getStatusClass(status: string): string { function TimelineSkeleton() { return (
- {Array.from({ length: 6 }).map((_, idx) => ( -
+ {[0, 1, 2, 3, 4, 5].map((val) => ( +
@@ -66,7 +69,7 @@ export default function TransactionHistory() { setIsLoading(true); setError(null); try { - const result = await fetchHistoryPage({ + const result: { items: TimelineItem[]; hasMore: boolean } = await fetchHistoryPage({ page: 1, limit: 20, filters: debouncedFilters, @@ -94,12 +97,12 @@ export default function TransactionHistory() { const nextPage = page + 1; setIsLoadingMore(true); try { - const result = await fetchHistoryPage({ + const result: { items: TimelineItem[]; hasMore: boolean } = await fetchHistoryPage({ page: nextPage, limit: 20, filters: debouncedFilters, }); - setItems((prev) => [...prev, ...result.items]); + setItems((prev: TimelineItem[]) => [...prev, ...result.items]); setPage(nextPage); setHasMore(result.hasMore); } catch (loadError) { diff --git a/frontend/src/services/bulkPaymentStatus.ts b/frontend/src/services/bulkPaymentStatus.ts new file mode 100644 index 00000000..7637a9b5 --- /dev/null +++ b/frontend/src/services/bulkPaymentStatus.ts @@ -0,0 +1,145 @@ +import { + BASE_FEE, + Contract, + Networks, + rpc, + TransactionBuilder, + nativeToScVal, +} from '@stellar/stellar-sdk'; +import { simulateTransaction } from './transactionSimulation'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const DEFAULT_RPC_URL = + (import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined) || + 'https://soroban-testnet.stellar.org'; + +export interface PayrollRunRecord { + id: number; + batch_id: string; + status: 'draft' | 'pending' | 'processing' | 'completed' | 'failed'; + total_amount: string; + asset_code: string; + created_at: string; +} + +export interface PayrollRecipientStatus { + id: number; + employee_id: number; + employee_first_name?: string; + employee_last_name?: string; + employee_email?: string; + amount: string; + status: 'pending' | 'completed' | 'failed'; + tx_hash?: string; +} + +export interface PayrollRunSummary { + payroll_run: PayrollRunRecord; + items: PayrollRecipientStatus[]; + summary: { + total_employees: number; + total_amount: string; + }; +} + +interface PayrollRunsListResponse { + success: boolean; + data: { + data: PayrollRunRecord[]; + total: number; + }; +} + +interface PayrollRunSummaryResponse { + success: boolean; + data: PayrollRunSummary; +} + +export interface RetryInvocationOptions { + contractId: string; + batchId: string; + sourceAddress: string; + signTransaction: (xdr: string) => Promise; + rpcUrl?: string; +} + +function getNetworkPassphrase(): string { + const network = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase(); + return network === 'MAINNET' ? Networks.PUBLIC : Networks.TESTNET; +} + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +export async function fetchPayrollRuns( + organizationId: number, + page = 1, + limit = 20 +): Promise<{ data: PayrollRunRecord[]; total: number }> { + const response = await fetch( + `${normalizeBaseUrl(API_BASE_URL)}/api/v1/payroll-bonus/runs?organizationId=${organizationId}&page=${page}&limit=${limit}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch payroll runs (${response.status})`); + } + + const payload = (await response.json()) as PayrollRunsListResponse; + return payload.data; +} + +export async function fetchPayrollRunSummary(runId: number): Promise { + const response = await fetch( + `${normalizeBaseUrl(API_BASE_URL)}/api/v1/payroll-bonus/runs/${runId}` + ); + if (!response.ok) { + throw new Error(`Failed to fetch payroll run summary (${response.status})`); + } + + const payload = (await response.json()) as PayrollRunSummaryResponse; + return payload.data; +} + +export function getTxExplorerUrl( + txHash: string, + network: 'testnet' | 'public' = 'testnet' +): string { + return `https://stellar.expert/explorer/${network}/tx/${txHash}`; +} + +export async function retryFailedBatch( + options: RetryInvocationOptions +): Promise<{ txHash: string }> { + const rpcUrl = normalizeBaseUrl(options.rpcUrl || DEFAULT_RPC_URL); + const server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const account = await server.getAccount(options.sourceAddress); + const contract = new Contract(options.contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call('retry_failed_batch', nativeToScVal(options.batchId))) + .setTimeout(60) + .build(); + + const simulation = await simulateTransaction({ + envelopeXdr: tx.toXDR(), + }); + + if (!simulation.success) { + throw new Error(simulation.description || 'Simulation failed for retry transaction'); + } + + const prepared = await server.prepareTransaction(tx); + const signedXdr = await options.signTransaction(prepared.toXDR()); + const signedTx = TransactionBuilder.fromXDR(signedXdr, getNetworkPassphrase()); + const submitted = await server.sendTransaction(signedTx); + + if (submitted.status === 'ERROR') { + throw new Error('Retry submission failed on Soroban RPC.'); + } + + return { txHash: submitted.hash }; +} diff --git a/frontend/src/services/crossAssetPayment.ts b/frontend/src/services/crossAssetPayment.ts new file mode 100644 index 00000000..1ae8606a --- /dev/null +++ b/frontend/src/services/crossAssetPayment.ts @@ -0,0 +1,146 @@ +import { + BASE_FEE, + Contract, + Networks, + rpc, + TransactionBuilder, + nativeToScVal, +} from '@stellar/stellar-sdk'; +import { simulateTransaction } from './transactionSimulation'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const DEFAULT_RPC_URL = + (import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined) || + 'https://soroban-testnet.stellar.org'; + +export interface ConversionPath { + id: string; + sourceAsset: string; + destinationAsset: string; + rate: number; + fee: number; + slippage: number; + estimatedDestinationAmount: number; + hops: string[]; +} + +export interface PathfindRequest { + fromAsset: string; + toAsset: string; + amount: number; +} + +export interface SubmitCrossAssetPaymentInput { + contractId: string; + sourceAddress: string; + signTransaction: (xdr: string) => Promise; + amount: number; + fromAsset: string; + toAsset: string; + receiver: string; + selectedPathId: string; + rpcUrlOverride?: string; +} + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function getNetworkPassphrase(): string { + const network = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase(); + return network === 'MAINNET' ? Networks.PUBLIC : Networks.TESTNET; +} + +function fallbackPaths(request: PathfindRequest): ConversionPath[] { + const baseRate = request.toAsset === 'NGN' ? 1550 : request.toAsset === 'BRL' ? 5.1 : 1.15; + const fastFee = Number((request.amount * 0.006).toFixed(4)); + const cheapFee = Number((request.amount * 0.003).toFixed(4)); + + return [ + { + id: 'path-fast', + sourceAsset: request.fromAsset, + destinationAsset: request.toAsset, + rate: baseRate, + fee: fastFee, + slippage: 0.35, + estimatedDestinationAmount: Number((request.amount * baseRate - fastFee).toFixed(4)), + hops: [request.fromAsset, 'XLM', request.toAsset], + }, + { + id: 'path-cheap', + sourceAsset: request.fromAsset, + destinationAsset: request.toAsset, + rate: Number((baseRate * 0.994).toFixed(6)), + fee: cheapFee, + slippage: 0.8, + estimatedDestinationAmount: Number((request.amount * baseRate * 0.994 - cheapFee).toFixed(4)), + hops: [request.fromAsset, 'USDC', request.toAsset], + }, + ]; +} + +export async function fetchConversionPaths(request: PathfindRequest): Promise { + const endpoint = `${normalizeBaseUrl(API_BASE_URL)}/api/v1/payments/pathfind`; + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`Pathfinding endpoint unavailable (${response.status})`); + } + + const payload = (await response.json()) as { paths?: ConversionPath[] }; + if (!payload.paths?.length) { + return fallbackPaths(request); + } + return payload.paths; + } catch { + return fallbackPaths(request); + } +} + +export async function submitCrossAssetPayment( + input: SubmitCrossAssetPaymentInput +): Promise<{ txHash: string }> { + const rpcUrl = normalizeBaseUrl(input.rpcUrlOverride || DEFAULT_RPC_URL); + const server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const account = await server.getAccount(input.sourceAddress); + const contract = new Contract(input.contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation( + contract.call( + 'execute_cross_asset_payment', + nativeToScVal(input.receiver), + nativeToScVal(input.fromAsset), + nativeToScVal(input.toAsset), + nativeToScVal(input.amount), + nativeToScVal(input.selectedPathId) + ) + ) + .setTimeout(60) + .build(); + + const simulation = await simulateTransaction({ envelopeXdr: tx.toXDR() }); + if (!simulation.success) { + throw new Error(simulation.description || 'Simulation failed for cross-asset payment'); + } + + const prepared = await server.prepareTransaction(tx); + const signedXdr = await input.signTransaction(prepared.toXDR()); + const signedTx = TransactionBuilder.fromXDR(signedXdr, getNetworkPassphrase()); + const submitted = await server.sendTransaction(signedTx); + + if (submitted.status === 'ERROR') { + throw new Error('Cross-asset contract submission failed.'); + } + + return { txHash: submitted.hash }; +} diff --git a/frontend/src/services/revenueSplit.ts b/frontend/src/services/revenueSplit.ts new file mode 100644 index 00000000..eb89c9ed --- /dev/null +++ b/frontend/src/services/revenueSplit.ts @@ -0,0 +1,210 @@ +import { + BASE_FEE, + Contract, + Networks, + rpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { simulateTransaction } from './transactionSimulation'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const DEFAULT_RPC_URL = + (import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined) || + 'https://soroban-testnet.stellar.org'; + +const GET_ALLOCATIONS_METHOD = + (import.meta.env.VITE_REVENUE_SPLIT_GET_ALLOCATIONS_METHOD as string | undefined) || + 'get_allocations'; +const UPDATE_ALLOCATIONS_METHOD = + (import.meta.env.VITE_REVENUE_SPLIT_UPDATE_ALLOCATIONS_METHOD as string | undefined) || + 'set_allocations'; + +export interface RevenueAllocation { + recipient: string; + percentage: number; +} + +export interface DistributionEvent { + id: number; + createdAt: string; + txHash: string | null; + amount: number; + assetCode: string; + action: string; + recipientLabel: string; +} + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function getNetworkPassphrase(): string { + const network = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase(); + return network === 'MAINNET' ? Networks.PUBLIC : Networks.TESTNET; +} + +function toNumber(value: unknown): number { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function normalizeAllocationsFromNative(nativeValue: unknown): RevenueAllocation[] { + if (!Array.isArray(nativeValue)) return []; + + return nativeValue + .map((entry: unknown) => { + if (Array.isArray(entry)) { + const [recipient, percentageRaw] = entry as [unknown, unknown]; + return { + recipient: typeof recipient === 'string' ? recipient : '', + percentage: toNumber(percentageRaw), + }; + } + + if (entry && typeof entry === 'object') { + const item = entry as Record; + return { + recipient: + typeof item.recipient === 'string' + ? item.recipient + : typeof item.address === 'string' + ? item.address + : '', + percentage: toNumber(item.percentage ?? item.weight ?? item.share), + }; + } + + return { recipient: '', percentage: 0 }; + }) + .filter((entry) => entry.recipient); +} + +export async function fetchRevenueSplitAllocations( + contractId: string, + sourceAddress: string, + rpcUrlOverride?: string +): Promise { + const rpcUrl = normalizeBaseUrl(rpcUrlOverride || DEFAULT_RPC_URL); + const server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const account = await server.getAccount(sourceAddress); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call(GET_ALLOCATIONS_METHOD)) + .setTimeout(60) + .build(); + + const rpcResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'simulateTransaction', + params: { transaction: tx.toXDR() }, + }), + }); + + if (!rpcResponse.ok) { + throw new Error(`Failed to simulate allocation read (${rpcResponse.status})`); + } + + const payload = (await rpcResponse.json()) as { + result?: { retval?: string; error?: string }; + error?: { message?: string }; + }; + + if (payload.error?.message) { + throw new Error(payload.error.message); + } + + if (!payload.result?.retval) { + return []; + } + + const retval = xdr.ScVal.fromXDR(payload.result.retval, 'base64'); + const nativeValue: unknown = scValToNative(retval); + return normalizeAllocationsFromNative(nativeValue); +} + +export async function updateRevenueAllocations(options: { + contractId: string; + sourceAddress: string; + allocations: RevenueAllocation[]; + signTransaction: (xdr: string) => Promise; + rpcUrlOverride?: string; +}): Promise<{ txHash: string }> { + const rpcUrl = normalizeBaseUrl(options.rpcUrlOverride || DEFAULT_RPC_URL); + const server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const account = await server.getAccount(options.sourceAddress); + const contract = new Contract(options.contractId); + + const allocationPayload = options.allocations.map((entry) => [ + entry.recipient, + Number.parseFloat(entry.percentage.toFixed(4)), + ]); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call(UPDATE_ALLOCATIONS_METHOD, nativeToScVal(allocationPayload))) + .setTimeout(60) + .build(); + + const simulation = await simulateTransaction({ envelopeXdr: tx.toXDR() }); + if (!simulation.success) { + throw new Error(simulation.description || 'Simulation failed for allocation update'); + } + + const prepared = await server.prepareTransaction(tx); + const signedXdr = await options.signTransaction(prepared.toXDR()); + const signedTx = TransactionBuilder.fromXDR(signedXdr, getNetworkPassphrase()); + const submitted = await server.sendTransaction(signedTx); + + if (submitted.status === 'ERROR') { + throw new Error('Allocation update transaction failed.'); + } + + return { txHash: submitted.hash }; +} + +export async function fetchDistributionEvents( + organizationId: number, + page = 1, + limit = 30 +): Promise { + const response = await fetch( + `${normalizeBaseUrl(API_BASE_URL)}/api/v1/payroll/audit?organizationId=${organizationId}&page=${page}&limit=${limit}` + ); + if (!response.ok) { + throw new Error(`Failed to fetch distribution events (${response.status})`); + } + + const payload = (await response.json()) as { + success: boolean; + data: Array>; + }; + + return (payload.data || []).map((event: Record) => ({ + id: Number(event.id ?? 0), + createdAt: typeof event.created_at === 'string' ? event.created_at : '', + txHash: typeof event.tx_hash === 'string' ? event.tx_hash : null, + amount: toNumber(event.amount), + assetCode: typeof event.asset_code === 'string' ? event.asset_code : 'USDC', + action: typeof event.action === 'string' ? event.action : 'unknown', + recipientLabel: + `${typeof event.employee_first_name === 'string' ? event.employee_first_name : ''} ${typeof event.employee_last_name === 'string' ? event.employee_last_name : ''}`.trim() || + (typeof event.employee_email === 'string' ? event.employee_email : 'Unknown recipient'), + })); +} diff --git a/frontend/src/services/transactionHistory.ts b/frontend/src/services/transactionHistory.ts new file mode 100644 index 00000000..64a3a44f --- /dev/null +++ b/frontend/src/services/transactionHistory.ts @@ -0,0 +1,137 @@ +import { contractService } from './contracts'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + +export interface HistoryFilters { + search: string; + status: string; + employee: string; + asset: string; + startDate: string; + endDate: string; +} + +export interface TimelineItem { + id: string; + kind: 'classic' | 'contract'; + createdAt: string; + status: string; + amount: string; + asset: string; + actor: string; + txHash: string | null; + label: string; + badge: string; +} + +interface AuditResponse { + data: Array>; + total: number; + page: number; +} + +function asString(value: unknown, fallback = ''): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return fallback; +} + +function toQuery(params: Record): string { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === '') return; + query.set(key, String(value)); + }); + return query.toString(); +} + +function normalizeClassicItem(row: Record): TimelineItem { + const txHash = asString(row.tx_hash, '') || null; + return { + id: `audit-${asString(row.id, txHash ?? 'unknown')}`, + kind: 'classic', + createdAt: asString(row.created_at, asString(row.stellar_created_at, new Date().toISOString())), + status: (row.successful as boolean) === false ? 'failed' : 'confirmed', + amount: asString(row.fee_charged, '0'), + asset: 'XLM', + actor: asString(row.source_account, 'Unknown'), + txHash, + label: 'Classic Stellar Transaction', + badge: 'Classic', + }; +} + +function normalizeContractItem(contractId: string, row: Record): TimelineItem { + return { + id: `contract-${asString(row.event_id, asString(row.id, 'unknown'))}`, + kind: 'contract', + createdAt: asString(row.created_at, new Date().toISOString()), + status: 'indexed', + amount: asString((row.payload as Record | undefined)?.amount, '0'), + asset: asString((row.payload as Record | undefined)?.asset_code, 'N/A'), + actor: contractId, + txHash: asString(row.tx_hash, '') || null, + label: asString(row.event_type, 'contract_event'), + badge: 'Contract Event', + }; +} + +export async function fetchHistoryPage(options: { + page: number; + limit: number; + filters: HistoryFilters; +}): Promise<{ items: TimelineItem[]; hasMore: boolean }> { + const { page, limit, filters } = options; + const query = toQuery({ + page, + limit, + status: filters.status || undefined, + employee: filters.employee || undefined, + asset: filters.asset || undefined, + startDate: filters.startDate || undefined, + endDate: filters.endDate || undefined, + search: filters.search || undefined, + }); + + const auditResponse = await fetch(`${API_BASE_URL}/api/v1/audit?${query}`); + if (!auditResponse.ok) { + throw new Error(`Failed to fetch audit records (${auditResponse.status})`); + } + + const auditPayload = (await auditResponse.json()) as AuditResponse; + const classicItems = (auditPayload.data || []).map(normalizeClassicItem); + + await contractService.initialize(); + const contractIds = [ + contractService.getContractId('bulk_payment', 'testnet'), + contractService.getContractId('vesting_escrow', 'testnet'), + contractService.getContractId('revenue_split', 'testnet'), + ].filter((value): value is string => Boolean(value)); + + const contractItems: TimelineItem[] = []; + await Promise.all( + contractIds.map(async (contractId) => { + try { + const eventResponse = await fetch( + `${API_BASE_URL}/api/events/${contractId}?page=1&limit=10` + ); + if (!eventResponse.ok) return; + const payload = (await eventResponse.json()) as { + data?: Array>; + }; + (payload.data || []).forEach((row) => { + contractItems.push(normalizeContractItem(contractId, row)); + }); + } catch { + // Contract event index may not be available yet; continue with classic timeline. + } + }) + ); + + const merged = [...classicItems, ...contractItems].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + const hasMore = page * limit < (auditPayload.total || 0); + return { items: merged, hasMore }; +} From bfa3661fa80a8c5c7161cc63a335739696a9c4e4 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Sun, 8 Mar 2026 12:58:31 +0100 Subject: [PATCH 3/4] chore: full sync with integrated and linted state from local workspace --- backend/.well-known/stellar.toml | 26 + backend/package-lock.json | 2544 ++++------------- .../__tests__/multiTenantIsolation.test.ts | 2 +- .../benchmarks/sds-vs-horizon.benchmark.ts | 8 +- backend/src/config/env.ts | 2 - backend/src/config/index.ts | 5 - .../__tests__/authController.test.ts | 2 +- .../__tests__/contractController.test.ts | 157 + .../__tests__/employeeController.test.ts | 4 +- .../__tests__/freezeController.test.ts | 6 +- .../__tests__/healthController.test.ts | 6 +- .../__tests__/searchController.test.ts | 4 +- backend/src/controllers/assetController.ts | 2 +- backend/src/controllers/authController.ts | 2 +- backend/src/controllers/balanceController.ts | 6 +- .../src/controllers/bulkImportController.ts | 4 +- .../controllers/claimableBalanceController.ts | 71 - backend/src/controllers/contractController.ts | 71 + .../controllers/contractEventsController.ts | 62 + .../controllers/contractUpgradeController.ts | 274 ++ backend/src/controllers/employeeController.ts | 16 +- backend/src/controllers/exportController.ts | 20 +- backend/src/controllers/freezeController.ts | 4 +- backend/src/controllers/healthController.ts | 6 +- backend/src/controllers/multiSigController.ts | 8 +- .../src/controllers/payrollAuditController.ts | 10 +- .../src/controllers/payrollBonusController.ts | 12 +- .../src/controllers/rateLimitController.ts | 2 +- backend/src/controllers/searchController.ts | 10 +- backend/src/controllers/taxController.ts | 2 +- .../src/controllers/throttlingController.ts | 2 +- .../controllers/transactionAuditController.ts | 2 +- .../src/controllers/trustlineController.ts | 14 +- backend/src/controllers/webhook.controller.ts | 6 +- .../014_create_contract_registry.sql | 198 ++ .../migrations/015_create_contract_events.sql | 23 + .../017_add_timezone_to_schedules.sql | 2 + .../017_create_claimable_balances.sql | 44 - .../__tests__/tenantContext.test.ts | 4 +- backend/src/middleware/tenantContext.ts | 10 +- .../src/middlewares/apiVersionMiddleware.ts | 2 +- backend/src/middlewares/auth.ts | 72 +- .../src/middlewares/rateLimitMiddleware.ts | 7 +- backend/src/middlewares/rbac.ts | 6 +- backend/src/middlewares/require2fa.ts | 2 +- .../src/middlewares/throttlingMiddleware.ts | 2 +- backend/src/routes/assetRoutes.ts | 6 +- backend/src/routes/balanceRoutes.ts | 2 +- backend/src/routes/claimableBalanceRoutes.ts | 9 - backend/src/routes/contractRoutes.ts | 17 + backend/src/routes/contractUpgradeRoutes.ts | 83 + backend/src/routes/employeeRoutes.ts | 8 +- backend/src/routes/exportRoutes.ts | 2 +- backend/src/routes/freezeRoutes.ts | 4 +- backend/src/routes/multiSigRoutes.ts | 2 +- backend/src/routes/payroll.routes.ts | 16 +- backend/src/routes/payrollAuditRoutes.ts | 2 +- backend/src/routes/payrollBonusRoutes.ts | 2 +- backend/src/routes/rateLimitRoutes.ts | 2 +- backend/src/routes/searchRoutes.ts | 36 +- backend/src/routes/taxRoutes.ts | 2 +- backend/src/routes/throttlingRoutes.ts | 2 +- backend/src/routes/trustlineRoutes.ts | 2 +- backend/src/routes/v1/index.ts | 4 +- backend/src/schemas/employeeSchema.ts | 4 +- .../__tests__/contractConfigService.test.ts | 100 + .../__tests__/csvPayrollImportService.test.ts | 6 +- .../__tests__/employeeService.test.ts | 4 +- .../services/__tests__/exportService.test.ts | 4 +- .../services/__tests__/freezeService.test.ts | 6 +- .../__tests__/ledgerObserverService.test.ts | 170 -- .../__tests__/multiSigService.test.ts | 4 +- .../services/__tests__/searchService.test.ts | 2 +- .../services/__tests__/stellarService.test.ts | 2 +- .../src/services/__tests__/taxService.test.ts | 2 +- .../__tests__/throttlingService.test.ts | 2 +- backend/src/services/anchorService.ts | 7 +- backend/src/services/assetService.ts | 4 +- backend/src/services/balanceService.ts | 2 +- .../src/services/claimableBalanceService.ts | 239 -- backend/src/services/contractConfigService.ts | 176 ++ .../services/contractEventIndexerService.ts | 209 ++ .../src/services/contractUpgradeService.ts | 828 ++++++ .../src/services/csvPayrollImportService.ts | 8 +- backend/src/services/employeeService.ts | 4 +- backend/src/services/exportService.ts | 2 +- backend/src/services/freezeService.ts | 4 +- backend/src/services/ledgerObserverService.ts | 212 -- backend/src/services/multiSigService.ts | 2 +- .../src/services/payroll-indexing.service.ts | 6 +- backend/src/services/payroll-query.service.ts | 8 +- backend/src/services/payrollAuditService.ts | 4 +- backend/src/services/payrollBonusService.ts | 4 +- backend/src/services/rateLimitService.ts | 9 +- backend/src/services/sds.service.ts | 6 +- backend/src/services/searchService.ts | 2 +- backend/src/services/socketService.ts | 2 +- backend/src/services/taxService.ts | 2 +- backend/src/services/tenantConfigService.ts | 2 +- backend/src/services/trustlineService.ts | 4 +- backend/src/services/webhook.service.ts | 12 +- backend/src/stellar/client.ts | 39 +- backend/src/stellar/connectionTest.ts | 2 +- backend/src/stellar/horizonService.ts | 181 -- backend/src/stellar/index.ts | 12 +- backend/src/types/auth.ts | 6 +- backend/src/utils/contractValidator.ts | 79 + backend/src/utils/logger.ts | 11 - frontend/package-lock.json | 1004 ++++++- frontend/package.json | 5 +- frontend/src/App.tsx | 18 + frontend/src/components/AppNav.tsx | 18 + .../components/BulkPaymentStatusTracker.tsx | 386 +++ .../src/components/ContractUpgradeTab.tsx | 392 +++ .../src/components/UpgradeConfirmModal.tsx | 859 ++++++ frontend/src/hooks/useSorobanContract.ts | 208 ++ frontend/src/pages/AdminPanel.tsx | 11 +- frontend/src/pages/EmployeePortal.tsx | 91 - frontend/src/pages/PayrollScheduler.tsx | 5 + frontend/src/pages/Settings.tsx | 202 +- frontend/src/services/claimsApi.ts | 26 - frontend/src/services/contractUpgrade.ts | 220 ++ frontend/src/services/contracts.example.tsx | 124 + frontend/src/services/contracts.ts | 138 + frontend/src/services/contracts.types.ts | 26 + frontend/tsconfig.app.tsbuildinfo | 1 + 126 files changed, 6561 insertions(+), 3512 deletions(-) create mode 100644 backend/.well-known/stellar.toml create mode 100644 backend/src/controllers/__tests__/contractController.test.ts delete mode 100644 backend/src/controllers/claimableBalanceController.ts create mode 100644 backend/src/controllers/contractController.ts create mode 100644 backend/src/controllers/contractEventsController.ts create mode 100644 backend/src/controllers/contractUpgradeController.ts create mode 100644 backend/src/db/migrations/014_create_contract_registry.sql create mode 100644 backend/src/db/migrations/015_create_contract_events.sql create mode 100644 backend/src/db/migrations/017_add_timezone_to_schedules.sql delete mode 100644 backend/src/db/migrations/017_create_claimable_balances.sql delete mode 100644 backend/src/routes/claimableBalanceRoutes.ts create mode 100644 backend/src/routes/contractRoutes.ts create mode 100644 backend/src/routes/contractUpgradeRoutes.ts create mode 100644 backend/src/services/__tests__/contractConfigService.test.ts delete mode 100644 backend/src/services/__tests__/ledgerObserverService.test.ts delete mode 100644 backend/src/services/claimableBalanceService.ts create mode 100644 backend/src/services/contractConfigService.ts create mode 100644 backend/src/services/contractEventIndexerService.ts create mode 100644 backend/src/services/contractUpgradeService.ts delete mode 100644 backend/src/services/ledgerObserverService.ts delete mode 100644 backend/src/stellar/horizonService.ts create mode 100644 backend/src/utils/contractValidator.ts create mode 100644 frontend/src/components/BulkPaymentStatusTracker.tsx create mode 100644 frontend/src/components/ContractUpgradeTab.tsx create mode 100644 frontend/src/components/UpgradeConfirmModal.tsx create mode 100644 frontend/src/hooks/useSorobanContract.ts delete mode 100644 frontend/src/services/claimsApi.ts create mode 100644 frontend/src/services/contractUpgrade.ts create mode 100644 frontend/src/services/contracts.example.tsx create mode 100644 frontend/src/services/contracts.ts create mode 100644 frontend/src/services/contracts.types.ts create mode 100644 frontend/tsconfig.app.tsbuildinfo diff --git a/backend/.well-known/stellar.toml b/backend/.well-known/stellar.toml new file mode 100644 index 00000000..d6b78b54 --- /dev/null +++ b/backend/.well-known/stellar.toml @@ -0,0 +1,26 @@ +# SEP-0001 Stellar Metadata for PayD + +NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" +HORIZON_URL = "https://horizon-testnet.stellar.org" + +[DOCUMENTATION] +ORG_NAME = "PayD" +ORG_URL = "https://payd.example.com" +ORG_DESCRIPTION = "PayD is a Stellar-based cross-border payroll platform enabling organizations to pay employees using digital assets." +ORG_LOG_URL = "https://payd.example.com/logo.png" + +[CONTACT] +ORG_SUPPORT_EMAIL = "support@payd.example.com" + +[[CURRENCIES]] +code = "ORGUSD" +issuer = "GD7TPWEDZCAD3TTMI7OGEDHHVH4QIJPKKXWKT3NIXOFMVA5QJ4BEBVLF" +display_decimal = 2 +name = "PayD Organizational Dollar" +desc = "A stablecoin pegged to USD for payroll disbursements within the PayD platform." +status = "live" +conditions = "This asset is used for payroll and requires authorization from the issuer." +is_asset_anchored = true +anchor_asset_type = "other" +anchor_asset = "USD" +funding_methods = "Direct issuance to distribution accounts." diff --git a/backend/package-lock.json b/backend/package-lock.json index 7bee984c..121e3b4c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,14 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@sentry/node": "^10.42.0", - "@sentry/profiling-node": "^10.42.0", - "@stellar/stellar-sdk": "^14.3.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", + "luxon": "^3.7.2", "node-cron": "^4.2.1", "passport": "^0.7.0", "passport-github2": "^0.1.12", @@ -30,6 +28,7 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/luxon": "^3.7.1", "@types/node": "^25.3.0", "@types/node-cron": "^3.0.11", "@types/passport": "^1.0.17", @@ -75,6 +74,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -100,6 +100,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -117,6 +127,17 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", @@ -134,6 +155,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -553,17 +584,6 @@ "node": ">=12" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -598,76 +618,107 @@ "tslib": "^2.4.0" } }, - "node_modules/@fastify/otel": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", - "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.0.3" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" + "engines": { + "node": ">=12" } }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.3.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", - "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", - "license": "Apache-2.0", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=12" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@istanbuljs/load-nyc-config": { @@ -917,6 +968,65 @@ } } }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/schemas": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", @@ -961,6 +1071,17 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jest/test-result": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", @@ -1020,6 +1141,17 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jest/types": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", @@ -1050,6 +1182,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", @@ -1061,6 +1204,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1079,14 +1233,14 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -1102,25 +1256,11 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -1129,530 +1269,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", - "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", - "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", - "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.211.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", - "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", - "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", - "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", - "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", - "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", - "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", - "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", - "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", - "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/instrumentation": "0.211.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", - "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", - "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", - "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", - "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", - "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", - "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", - "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", - "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", - "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.63.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", - "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", - "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", - "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", - "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", - "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", - "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1687,198 +1303,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@prisma/instrumentation": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", - "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry-internal/node-cpu-profiler": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz", - "integrity": "sha512-oLHVYurqZfADPh5hvmQYS5qx8t0UZzT2u6+/68VXsFruQEOnYJTODKgU3BVLmemRs3WE6kCJjPeFdHVYOQGSzQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.3", - "node-abi": "^3.73.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.42.0.tgz", - "integrity": "sha512-ZZfU3Fnni7Aj0lTX4e3QpY3UxK4FGuzfM20316UAJycBGnripm+sDHwcekPMGfLnk/FrN9wa1atspVlHvOI0WQ==", - "license": "MIT", - "dependencies": { - "@fastify/otel": "0.16.0", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.5.1", - "@opentelemetry/core": "^2.5.1", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/instrumentation-amqplib": "0.58.0", - "@opentelemetry/instrumentation-connect": "0.54.0", - "@opentelemetry/instrumentation-dataloader": "0.28.0", - "@opentelemetry/instrumentation-express": "0.59.0", - "@opentelemetry/instrumentation-fs": "0.30.0", - "@opentelemetry/instrumentation-generic-pool": "0.54.0", - "@opentelemetry/instrumentation-graphql": "0.58.0", - "@opentelemetry/instrumentation-hapi": "0.57.0", - "@opentelemetry/instrumentation-http": "0.211.0", - "@opentelemetry/instrumentation-ioredis": "0.59.0", - "@opentelemetry/instrumentation-kafkajs": "0.20.0", - "@opentelemetry/instrumentation-knex": "0.55.0", - "@opentelemetry/instrumentation-koa": "0.59.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", - "@opentelemetry/instrumentation-mongodb": "0.64.0", - "@opentelemetry/instrumentation-mongoose": "0.57.0", - "@opentelemetry/instrumentation-mysql": "0.57.0", - "@opentelemetry/instrumentation-mysql2": "0.57.0", - "@opentelemetry/instrumentation-pg": "0.63.0", - "@opentelemetry/instrumentation-redis": "0.59.0", - "@opentelemetry/instrumentation-tedious": "0.30.0", - "@opentelemetry/instrumentation-undici": "0.21.0", - "@opentelemetry/resources": "^2.5.1", - "@opentelemetry/sdk-trace-base": "^2.5.1", - "@opentelemetry/semantic-conventions": "^1.39.0", - "@prisma/instrumentation": "7.2.0", - "@sentry/core": "10.42.0", - "@sentry/node-core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node-core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.42.0.tgz", - "integrity": "sha512-9tf3fPV6M071aps72D+PEtdQPTuj+SuqO2+PpTfdPP5ZL4TTKYo3VK0li76SL+5wGdTFGV5qmsokHq9IRBA0iA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/resources": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - }, - "@opentelemetry/semantic-conventions": { - "optional": true - } - } - }, - "node_modules/@sentry/opentelemetry": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.42.0.tgz", - "integrity": "sha512-5vsYz683iihzlIj3sT1+tEixf0awwXK86a+aYsnMHrTXJDrkBDq4U0ZT+yxdPfJlkaxRtYycFR08SXr2pSm7Eg==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - } - }, - "node_modules/@sentry/profiling-node": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/profiling-node/-/profiling-node-10.42.0.tgz", - "integrity": "sha512-HmQn5rLBdAigxMpliasGV2WclDotO64XWJqOXbK5aoNg4rdqzi1BrB9O1wVN2/TDOswnz579X8d6lzTD0g1u6w==", - "license": "MIT", - "dependencies": { - "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.42.0", - "@sentry/node": "10.42.0" - }, - "bin": { - "sentry-prune-profiler-binaries": "scripts/prune-profiler-binaries.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -1906,52 +1330,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0" - }, - "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", - "license": "Apache-2.0", - "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", - "buffer": "^6.0.3", - "sha.js": "^2.4.12" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -2051,6 +1429,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2154,6 +1533,13 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2168,20 +1554,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2250,9 +1629,10 @@ } }, "node_modules/@types/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2260,19 +1640,10 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, @@ -2349,15 +1720,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2488,9 +1850,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2505,9 +1864,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2522,9 +1878,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2539,9 +1892,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2556,9 +1906,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2573,9 +1920,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2590,9 +1934,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2607,9 +1948,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2692,6 +2030,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2700,15 +2039,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -2739,16 +2069,13 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -2809,34 +2136,9 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -2925,53 +2227,22 @@ "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base32.js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64url": { @@ -2996,15 +2267,6 @@ "node": ">=6.0.0" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3043,15 +2305,14 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -3087,6 +2348,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3124,30 +2386,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3170,24 +2408,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3238,9 +2458,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", "dev": true, "funding": [ { @@ -3330,6 +2550,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, "license": "MIT" }, "node_modules/cliui": { @@ -3347,69 +2568,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3452,6 +2610,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3460,15 +2619,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -3621,27 +2771,11 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3656,15 +2790,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3755,9 +2880,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -3775,9 +2900,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -3834,6 +2959,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3894,15 +3020,6 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3927,13 +3044,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/exit-x": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", @@ -4029,15 +3139,6 @@ "bser": "2.1.1" } }, - "node_modules/feaxios": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4086,41 +3187,6 @@ "node": ">=8" } }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4138,10 +3204,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4158,6 +3238,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4167,6 +3248,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -4202,12 +3284,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -4329,71 +3405,38 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, "node_modules/gopd": { @@ -4447,18 +3490,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4475,6 +3506,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4560,38 +3592,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4669,18 +3669,6 @@ "node": ">=8" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4756,18 +3744,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-retry-allowed": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4781,27 +3757,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4836,19 +3791,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -4879,6 +3821,17 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -4915,6 +3868,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5068,6 +4022,67 @@ } } }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-diff": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", @@ -5320,6 +4335,17 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/jest-runtime": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", @@ -5354,6 +4380,64 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-snapshot": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", @@ -5387,19 +4471,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-util": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", @@ -5579,28 +4650,16 @@ "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "engines": { - "node": ">=10" + "node": ">=12", + "npm": ">=6" } }, "node_modules/jwa": { @@ -5713,6 +4772,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5729,19 +4797,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -5869,18 +4924,16 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -5916,12 +4969,6 @@ "node": ">=10" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5967,30 +5014,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -6008,9 +5031,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -6337,14 +5360,15 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -6371,9 +5395,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -6386,18 +5410,18 @@ } }, "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", "license": "MIT" }, "node_modules/pg-types": { @@ -6468,15 +5492,6 @@ "node": ">=8" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -6557,12 +5572,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -6595,15 +5604,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6658,19 +5658,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6729,59 +5716,6 @@ "rimraf": "bin.js" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6825,13 +5759,15 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -6879,49 +5815,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7018,17 +5917,11 @@ } }, "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "ISC" }, "node_modules/slash": { "version": "3.0.0", @@ -7051,9 +5944,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -7113,45 +6006,19 @@ "node": ">=10" } }, - "node_modules/string-length/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-length/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -7170,24 +6037,7 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { + "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -7200,22 +6050,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", @@ -7229,25 +6063,15 @@ "engines": { "node": ">=8" } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/strip-final-newline": { @@ -7261,16 +6085,13 @@ } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, "node_modules/superagent": { @@ -7366,59 +6187,6 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7426,20 +6194,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7531,19 +6285,6 @@ } } }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -7563,6 +6304,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7649,26 +6391,6 @@ "strip-json-comments": "^2.0.0" } }, - "node_modules/tsconfig/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tsconfig/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7714,26 +6436,13 @@ "node": ">= 0.6" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7766,6 +6475,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -7843,12 +6553,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -7880,6 +6584,17 @@ "node": ">=10.12.0" } }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7915,27 +6630,6 @@ "node": ">= 8" } }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -7944,18 +6638,18 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -7980,64 +6674,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8058,6 +6694,19 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -8113,51 +6762,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/backend/src/__tests__/multiTenantIsolation.test.ts b/backend/src/__tests__/multiTenantIsolation.test.ts index fc2b1fbb..9463f27e 100644 --- a/backend/src/__tests__/multiTenantIsolation.test.ts +++ b/backend/src/__tests__/multiTenantIsolation.test.ts @@ -6,7 +6,7 @@ */ import { Pool } from 'pg'; -import { config } from '../config/env'; +import { config } from '../config/env.js'; describe('Multi-Tenant Data Isolation', () => { let pool: Pool; diff --git a/backend/src/benchmarks/sds-vs-horizon.benchmark.ts b/backend/src/benchmarks/sds-vs-horizon.benchmark.ts index 917774d8..c798a110 100644 --- a/backend/src/benchmarks/sds-vs-horizon.benchmark.ts +++ b/backend/src/benchmarks/sds-vs-horizon.benchmark.ts @@ -1,8 +1,8 @@ import Server from '@stellar/stellar-sdk'; -import { sdsClient, SDSTransaction } from '../services/sds.service'; -import { payrollQueryService } from '../services/payroll-query.service'; -import { parsePaginationParams } from '../utils/pagination'; -import logger from '../utils/logger'; +import { sdsClient, SDSTransaction } from '../services/sds.service.js'; +import { payrollQueryService } from '../services/payroll-query.service.js'; +import { parsePaginationParams } from '../utils/pagination.js'; +import logger from '../utils/logger.js'; interface BenchmarkResult { operation: string; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index c42b1fa6..52455c44 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -9,8 +9,6 @@ const envSchema = z.object({ REDIS_URL: z.string().optional(), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), CORS_ORIGIN: z.string().default('http://localhost:5173'), - JWT_SECRET: z.string().default('your-secret-key'), - JWT_REFRESH_SECRET: z.string().default('your-refresh-secret-key'), THROTTLING_TPM: z.string().default('100'), THROTTLING_MAX_QUEUE_SIZE: z.string().default('1000'), THROTTLING_REFILL_INTERVAL_MS: z.string().default('1000'), diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 82c3bb3f..34325dae 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -43,11 +43,6 @@ export const config = { logging: { level: process.env.LOG_LEVEL || 'info', }, - - // Sentry - sentry: { - dsn: process.env.SENTRY_DSN, - }, }; export default config; diff --git a/backend/src/controllers/__tests__/authController.test.ts b/backend/src/controllers/__tests__/authController.test.ts index 9b154326..aecaa178 100644 --- a/backend/src/controllers/__tests__/authController.test.ts +++ b/backend/src/controllers/__tests__/authController.test.ts @@ -1,6 +1,6 @@ import request from 'supertest'; import express from 'express'; -import authRoutes from '../../routes/authRoutes'; +import authRoutes from '../../routes/authRoutes.js'; import { authenticator } from '@otplib/preset-default'; import pg from 'pg'; diff --git a/backend/src/controllers/__tests__/contractController.test.ts b/backend/src/controllers/__tests__/contractController.test.ts new file mode 100644 index 00000000..9c6543e4 --- /dev/null +++ b/backend/src/controllers/__tests__/contractController.test.ts @@ -0,0 +1,157 @@ +/** + * Contract Controller Tests + * Tests for the Contract Address Registry API endpoint + */ + +import { Request, Response } from 'express'; +import { ContractController } from '../contractController.js'; +import { ContractConfigService } from '../../services/contractConfigService.js'; +import { ContractEntry } from '../../utils/contractValidator.js'; + +// Mock the config service +jest.mock('../../services/contractConfigService'); +jest.mock('../../utils/logger'); + +describe('ContractController', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockJson: jest.Mock; + let mockStatus: jest.Mock; + let mockSetHeader: jest.Mock; + + beforeEach(() => { + mockJson = jest.fn(); + mockStatus = jest.fn().mockReturnThis(); + mockSetHeader = jest.fn(); + + mockRequest = {}; + mockResponse = { + json: mockJson, + status: mockStatus, + setHeader: mockSetHeader, + }; + + jest.clearAllMocks(); + }); + + describe('getContracts', () => { + it('should return valid contract entries with proper headers', async () => { + const mockEntries: ContractEntry[] = [ + { + contractId: 'CABC123456789012345678901234567890123456789012345678901234', + network: 'testnet', + contractType: 'bulk_payment', + version: '1.0.0', + deployedAt: 12345, + }, + { + contractId: 'CDEF123456789012345678901234567890123456789012345678901234', + network: 'testnet', + contractType: 'vesting_escrow', + version: '1.0.0', + deployedAt: 12346, + }, + ]; + + const mockConfigService = ContractConfigService as jest.MockedClass< + typeof ContractConfigService + >; + mockConfigService.prototype.getContractEntries = jest.fn().mockReturnValue(mockEntries); + + await ContractController.getContracts( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockSetHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockSetHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600'); + expect(mockStatus).toHaveBeenCalledWith(200); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + contracts: mockEntries, + count: 2, + timestamp: expect.any(String), + }) + ); + }); + + it('should filter out invalid contract entries', async () => { + const mockEntries: Partial[] = [ + { + contractId: 'CABC123456789012345678901234567890123456789012345678901234', + network: 'testnet', + contractType: 'bulk_payment', + version: '1.0.0', + deployedAt: 12345, + }, + { + contractId: 'INVALID', + network: 'testnet', + contractType: 'vesting_escrow', + version: '1.0.0', + deployedAt: 12346, + }, + ]; + + const mockConfigService = ContractConfigService as jest.MockedClass< + typeof ContractConfigService + >; + mockConfigService.prototype.getContractEntries = jest.fn().mockReturnValue(mockEntries); + + await ContractController.getContracts( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + count: 1, + }) + ); + }); + + it('should handle errors gracefully', async () => { + const mockConfigService = ContractConfigService as jest.MockedClass< + typeof ContractConfigService + >; + mockConfigService.prototype.getContractEntries = jest + .fn() + .mockImplementation(() => { + throw new Error('Configuration error'); + }); + + await ContractController.getContracts( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockStatus).toHaveBeenCalledWith(500); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Internal Server Error', + message: 'Configuration error', + timestamp: expect.any(String), + }) + ); + }); + + it('should return empty array when no contracts configured', async () => { + const mockConfigService = ContractConfigService as jest.MockedClass< + typeof ContractConfigService + >; + mockConfigService.prototype.getContractEntries = jest.fn().mockReturnValue([]); + + await ContractController.getContracts( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ + contracts: [], + count: 0, + }) + ); + }); + }); +}); diff --git a/backend/src/controllers/__tests__/employeeController.test.ts b/backend/src/controllers/__tests__/employeeController.test.ts index 3649a3f1..adc2ac74 100644 --- a/backend/src/controllers/__tests__/employeeController.test.ts +++ b/backend/src/controllers/__tests__/employeeController.test.ts @@ -9,8 +9,8 @@ jest.mock('../../config/env', () => ({ }, })); -import employeeRoutes from '../../routes/employeeRoutes'; -import { employeeService } from '../../services/employeeService'; +import employeeRoutes from '../../routes/employeeRoutes.js'; +import { employeeService } from '../../services/employeeService.js'; // Mock the employee service jest.mock('../../services/employeeService'); diff --git a/backend/src/controllers/__tests__/freezeController.test.ts b/backend/src/controllers/__tests__/freezeController.test.ts index 4696c876..533cc65e 100644 --- a/backend/src/controllers/__tests__/freezeController.test.ts +++ b/backend/src/controllers/__tests__/freezeController.test.ts @@ -1,7 +1,7 @@ import request from 'supertest'; import express from 'express'; import { Keypair } from '@stellar/stellar-sdk'; -import { FreezeResult, FreezePage } from '../../services/freezeService'; +import { FreezeResult, FreezePage } from '../../services/freezeService.js'; // --------------------------------------------------------------------------- // Mock FreezeService with an explicit factory so the real module (and its @@ -24,8 +24,8 @@ jest.mock('../../middlewares/rateLimitMiddleware', () => ({ rateLimitMiddleware: () => (_req: any, _res: any, next: any) => next(), })); -import freezeRoutes from '../../routes/freezeRoutes'; -import { FreezeService } from '../../services/freezeService'; +import freezeRoutes from '../../routes/freezeRoutes.js'; +import { FreezeService } from '../../services/freezeService.js'; // --------------------------------------------------------------------------- // Build a minimal Express app that mirrors how the real server mounts the diff --git a/backend/src/controllers/__tests__/healthController.test.ts b/backend/src/controllers/__tests__/healthController.test.ts index 53c06ba0..5dc2f520 100644 --- a/backend/src/controllers/__tests__/healthController.test.ts +++ b/backend/src/controllers/__tests__/healthController.test.ts @@ -1,9 +1,9 @@ import request from 'supertest'; import express from 'express'; -import { HealthController } from '../healthController'; +import { HealthController } from '../healthController.js'; import pg from 'pg'; -import Redis from 'ioredis'; -import { StellarService } from '../../services/stellarService'; +import { Redis } from 'ioredis'; +import { StellarService } from '../../services/stellarService.js'; jest.mock('pg', () => { const mPool = { query: jest.fn() }; diff --git a/backend/src/controllers/__tests__/searchController.test.ts b/backend/src/controllers/__tests__/searchController.test.ts index 53483ced..457b6e9b 100644 --- a/backend/src/controllers/__tests__/searchController.test.ts +++ b/backend/src/controllers/__tests__/searchController.test.ts @@ -9,8 +9,8 @@ jest.mock('../../config/env', () => ({ }, })); -import searchRoutes from '../../routes/searchRoutes'; -import searchService from '../../services/searchService'; +import searchRoutes from '../../routes/searchRoutes.js'; +import searchService from '../../services/searchService.js'; // Mock the search service jest.mock('../../services/searchService'); diff --git a/backend/src/controllers/assetController.ts b/backend/src/controllers/assetController.ts index 9b5387af..48ae835b 100644 --- a/backend/src/controllers/assetController.ts +++ b/backend/src/controllers/assetController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { AssetService } from '../services/assetService'; +import { AssetService } from '../services/assetService.js'; import { Keypair } from '@stellar/stellar-sdk'; export class AssetController { diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index 906d1c3e..22071b7f 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -3,7 +3,7 @@ import { authenticator } from '@otplib/preset-default'; import QRCode from 'qrcode'; import crypto from 'crypto'; import { Pool } from 'pg'; -import { config } from '../config/env'; +import { config } from '../config/env.js'; import jwt from 'jsonwebtoken'; const pool = new Pool({ connectionString: config.DATABASE_URL }); diff --git a/backend/src/controllers/balanceController.ts b/backend/src/controllers/balanceController.ts index 4be19834..f54ff764 100644 --- a/backend/src/controllers/balanceController.ts +++ b/backend/src/controllers/balanceController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { z } from 'zod'; -import { BalanceService } from '../services/balanceService'; +import { BalanceService } from '../services/balanceService.js'; const paymentEntrySchema = z.object({ employeeId: z.string().min(1), @@ -35,7 +35,7 @@ export class BalanceController { return res.status(400).json({ error: 'Missing or invalid assetIssuer query param.' }); } - const result = await BalanceService.getOrgUsdBalance(accountId, String(assetIssuer)); + const result = await BalanceService.getOrgUsdBalance(accountId as string, String(assetIssuer)); res.json({ account: accountId, @@ -77,7 +77,7 @@ export class BalanceController { }); } catch (error: any) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.errors }); + return res.status(400).json({ error: 'Validation Error', details: error.issues }); } if (error?.response?.status === 404) { return res.status(404).json({ error: 'Distribution account not found on Horizon.' }); diff --git a/backend/src/controllers/bulkImportController.ts b/backend/src/controllers/bulkImportController.ts index f3156a0d..b7ce9275 100644 --- a/backend/src/controllers/bulkImportController.ts +++ b/backend/src/controllers/bulkImportController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; -import { csvPayrollImportService } from '../services/csvPayrollImportService'; -import logger from '../utils/logger'; +import { csvPayrollImportService } from '../services/csvPayrollImportService.js'; +import logger from '../utils/logger.js'; export class BulkImportController { async import(req: Request, res: Response) { diff --git a/backend/src/controllers/claimableBalanceController.ts b/backend/src/controllers/claimableBalanceController.ts deleted file mode 100644 index 3de789d4..00000000 --- a/backend/src/controllers/claimableBalanceController.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Request, Response } from 'express'; -import { z } from 'zod'; -import { ClaimableBalanceService } from '../services/claimableBalanceService.js'; - -const createForEmployeeSchema = z.object({ - organizationId: z.number().int().positive(), - employeeId: z.number().int().positive(), - amount: z.string().min(1), - assetIssuer: z.string().length(56), -}); - -const listPendingSchema = z.object({ - organizationId: z.string().regex(/^\d+$/).transform(Number).optional(), - walletAddress: z.string().length(56), -}); - -export class ClaimableBalanceController { - static async createForEmployee(req: Request, res: Response) { - try { - const { organizationId, employeeId, amount, assetIssuer } = createForEmployeeSchema.parse( - req.body - ); - - const wallet = await ClaimableBalanceService.ensureCustodialWallet({ - organizationId, - employeeId, - }); - - const claim = await ClaimableBalanceService.createOrgUsdClaimableBalance({ - organizationId, - employeeId, - amount, - assetIssuer, - claimantPublicKey: wallet.publicKey, - }); - - res.status(201).json({ - success: true, - data: { - ...claim, - claimantPublicKey: wallet.publicKey, - instructions: - 'A claimable balance was created for your custodial wallet. Add the ORGUSD trustline, then claim the balance using your wallet application.', - }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.issues }); - } - res.status(500).json({ error: (error as Error).message || 'Failed to create claimable balance' }); - } - } - - static async listPendingForWallet(req: Request, res: Response) { - try { - const { organizationId, walletAddress } = listPendingSchema.parse(req.query); - - const claims = await ClaimableBalanceService.listPendingClaimsForWallet({ - organizationId: organizationId ?? null, - walletAddress, - }); - - res.json({ success: true, data: claims }); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.issues }); - } - res.status(500).json({ error: (error as Error).message || 'Failed to list pending claims' }); - } - } -} diff --git a/backend/src/controllers/contractController.ts b/backend/src/controllers/contractController.ts new file mode 100644 index 00000000..0b3eacbf --- /dev/null +++ b/backend/src/controllers/contractController.ts @@ -0,0 +1,71 @@ +/** + * Contract Controller + * Handles requests for the Contract Address Registry API + */ + +import { Request, Response } from 'express'; +import { ContractConfigService } from '../services/contractConfigService.js'; +import { validateContractEntry, ContractEntry } from '../utils/contractValidator.js'; +import logger from '../utils/logger.js'; + +export class ContractController { + private static configService = new ContractConfigService(); + + /** + * GET /api/contracts + * Returns all deployed contract addresses with metadata + */ + static async getContracts(req: Request, res: Response): Promise { + const startTime = Date.now(); + + try { + // Fetch contract entries from configuration + const rawEntries = ContractController.configService.getContractEntries(); + + // Validate and filter entries + const validEntries: ContractEntry[] = []; + + for (const entry of rawEntries) { + const validation = validateContractEntry(entry); + + if (validation.isValid) { + validEntries.push(entry as ContractEntry); + } else { + logger.warn( + `Invalid contract entry for ${entry.contractType} on ${entry.network}`, + { errors: validation.errors } + ); + } + } + + // Format response + const response = { + contracts: validEntries, + timestamp: new Date().toISOString(), + count: validEntries.length + }; + + // Set headers + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache + + // Check response time + const responseTime = Date.now() - startTime; + if (responseTime > 500) { + logger.warn(`Response time exceeded 500ms: ${responseTime}ms`); + } + + res.status(200).json(response); + } catch (error) { + logger.error('Error in getContracts', error); + + const errorResponse = { + error: 'Internal Server Error', + message: error instanceof Error ? error.message : 'Failed to retrieve contract registry', + timestamp: new Date().toISOString() + }; + + res.status(500).json(errorResponse); + } + } +} diff --git a/backend/src/controllers/contractEventsController.ts b/backend/src/controllers/contractEventsController.ts new file mode 100644 index 00000000..ead7682b --- /dev/null +++ b/backend/src/controllers/contractEventsController.ts @@ -0,0 +1,62 @@ +import { Request, Response } from 'express'; +import { query } from '../config/database.js'; + +export class ContractEventsController { + static async listByContract(req: Request, res: Response) { + try { + const { contractId } = req.params; + const page = Math.max(Number.parseInt((req.query.page as string) || '1', 10), 1); + const limit = Math.min( + Math.max(Number.parseInt((req.query.limit as string) || '20', 10), 1), + 100 + ); + const offset = (page - 1) * limit; + const eventTypeRaw = req.query.eventType; + const eventType = typeof eventTypeRaw === 'string' ? eventTypeRaw.trim() : undefined; + + const params: Array = [contractId as string]; + + let whereClause = 'WHERE contract_id = $1'; + + if (eventType) { + params.push(eventType); + whereClause += ` AND event_type = $${params.length}`; + } + + const countResult = await query( + `SELECT COUNT(*)::int AS total + FROM contract_events + ${whereClause}`, + params + ); + + params.push(limit); + params.push(offset); + const dataResult = await query( + `SELECT event_id, contract_id, event_type, payload, ledger_sequence, tx_hash, created_at + FROM contract_events + ${whereClause} + ORDER BY ledger_sequence DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params + ); + + res.json({ + success: true, + data: dataResult.rows, + pagination: { + page, + limit, + total: countResult.rows[0]?.total || 0, + totalPages: Math.ceil((countResult.rows[0]?.total || 0) / limit), + }, + }); + } catch (error) { + console.error('Failed to fetch contract events:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch contract events', + }); + } + } +} diff --git a/backend/src/controllers/contractUpgradeController.ts b/backend/src/controllers/contractUpgradeController.ts new file mode 100644 index 00000000..920eae9f --- /dev/null +++ b/backend/src/controllers/contractUpgradeController.ts @@ -0,0 +1,274 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import { ContractUpgradeService } from '../services/contractUpgradeService.js'; + +// --------------------------------------------------------------------------- +// Validation schemas — defined once at module scope (O(1) memory) +// --------------------------------------------------------------------------- + +/** Lowercase 64-char hex — SHA-256 of the WASM bytecode. */ +const wasmHashSchema = z + .string() + .length(64, 'WASM hash must be exactly 64 hex characters.') + .regex(/^[0-9a-f]{64}$/i, 'WASM hash must be a valid lowercase hex string.'); + +const simulateBodySchema = z.object({ + newWasmHash: wasmHashSchema, + initiatedBy: z.string().min(56).max(64, 'initiatedBy must be a Stellar public key (G...)'), + notes: z.string().max(1000).optional(), +}); + +const executeBodySchema = z.object({ + adminSecret: z + .string() + .min(56, 'adminSecret must be a valid Stellar secret key (S...)'), +}); + +const validateHashBodySchema = z.object({ + newWasmHash: wasmHashSchema, +}); + +const registryIdSchema = z.object({ + registryId: z.coerce.number().int().positive(), +}); + +const upgradeLogIdSchema = z.object({ + logId: z.coerce.number().int().positive(), +}); + +const listLogsQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), +}); + +// --------------------------------------------------------------------------- +// ContractUpgradeController +// --------------------------------------------------------------------------- + +export class ContractUpgradeController { + // ------------------------------------------------------------------------- + // GET /api/v1/contracts + // ------------------------------------------------------------------------- + + /** + * List all registered Soroban contracts with their current WASM hash. + */ + static async listContracts(_req: Request, res: Response): Promise { + try { + const contracts = await ContractUpgradeService.listContracts(); + res.status(200).json({ success: true, data: contracts, total: contracts.length }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // GET /api/v1/contracts/:registryId + // ------------------------------------------------------------------------- + + /** + * Retrieve a single contract by its registry DB id. + */ + static async getContract(req: Request, res: Response): Promise { + try { + const { registryId } = registryIdSchema.parse(req.params); + const contract = await ContractUpgradeService.getContract(registryId); + + if (!contract) { + res.status(404).json({ error: 'Contract not found in registry.' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // POST /api/v1/contracts/:registryId/validate-hash + // ------------------------------------------------------------------------- + + /** + * Validate a candidate WASM hash against: + * 1. Format (64 lowercase hex chars) + * 2. Difference from current deployed hash + * 3. On-chain existence (Soroban RPC) + * + * Body: { newWasmHash } + */ + static async validateHash(req: Request, res: Response): Promise { + try { + const { registryId } = registryIdSchema.parse(req.params); + const { newWasmHash } = validateHashBodySchema.parse(req.body); + + const result = await ContractUpgradeService.validateWasmHash(registryId, newWasmHash); + + res.status(200).json({ success: true, ...result }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // POST /api/v1/contracts/:registryId/simulate-upgrade + // ------------------------------------------------------------------------- + + /** + * Simulate the upgrade transaction via Soroban RPC. + * Creates an upgrade log row and returns simulation cost/result. + * + * Body: { newWasmHash, initiatedBy, notes? } + */ + static async simulateUpgrade(req: Request, res: Response): Promise { + try { + const { registryId } = registryIdSchema.parse(req.params); + const body = simulateBodySchema.parse(req.body); + + const { upgradeLogId, simulation } = await ContractUpgradeService.simulateUpgrade( + registryId, + body.newWasmHash, + body.initiatedBy, + body.notes + ); + + res.status(200).json({ + success: true, + upgradeLogId, + simulation, + message: simulation.success + ? 'Simulation passed. Review the diff and confirm to proceed.' + : 'Simulation failed. Resolve the reported error before proceeding.', + }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // POST /api/v1/contracts/upgrade-logs/:logId/execute + // ------------------------------------------------------------------------- + + /** + * Execute a previously simulated upgrade on-chain. + * Signs and submits the upgrade transaction, then triggers migration steps. + * + * Body: { adminSecret } + */ + static async executeUpgrade(req: Request, res: Response): Promise { + try { + const { logId } = upgradeLogIdSchema.parse(req.params); + const { adminSecret } = executeBodySchema.parse(req.body); + + const result = await ContractUpgradeService.executeUpgrade(logId, adminSecret); + + res.status(200).json({ + success: true, + ...result, + message: 'Upgrade transaction submitted. Poll /status for migration progress.', + }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // GET /api/v1/contracts/upgrade-logs/:logId/status + // ------------------------------------------------------------------------- + + /** + * Poll the current status of an upgrade log including migration step progress. + * Designed for repeated short-interval polling from the frontend. + */ + static async getUpgradeStatus(req: Request, res: Response): Promise { + try { + const { logId } = upgradeLogIdSchema.parse(req.params); + const log = await ContractUpgradeService.getUpgradeLogStatus(logId); + + if (!log) { + res.status(404).json({ error: 'Upgrade log not found.' }); + return; + } + + res.status(200).json({ success: true, data: log }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // GET /api/v1/contracts/:registryId/upgrade-logs + // ------------------------------------------------------------------------- + + /** + * Paginated upgrade history for a specific contract. + * + * Query params: ?page=1&limit=20 + */ + static async listUpgradeLogs(req: Request, res: Response): Promise { + try { + const { registryId } = registryIdSchema.parse(req.params); + const { page, limit } = listLogsQuerySchema.parse(req.query); + + const result = await ContractUpgradeService.listUpgradeLogs(registryId, page, limit); + + res.status(200).json({ success: true, ...result }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // POST /api/v1/contracts/upgrade-logs/:logId/cancel + // ------------------------------------------------------------------------- + + /** + * Cancel a pending or simulated upgrade before it has been executed. + */ + static async cancelUpgrade(req: Request, res: Response): Promise { + try { + const { logId } = upgradeLogIdSchema.parse(req.params); + await ContractUpgradeService.cancelUpgrade(logId); + + res.status(200).json({ success: true, message: 'Upgrade cancelled successfully.' }); + } catch (error: unknown) { + ContractUpgradeController.handleError(error, res); + } + } + + // ------------------------------------------------------------------------- + // Shared error handler + // ------------------------------------------------------------------------- + + /** + * Centralised error mapper — keeps action methods free of repetitive + * try/catch blocks with status-code branching. + */ + private static handleError(error: unknown, res: Response): void { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Validation Error', details: error.issues }); + return; + } + + + // Stellar SDK throws when a secret key is invalid + const msg = error instanceof Error ? error.message : ''; + if (msg.includes('invalid') || msg.includes('Invalid') || msg.includes('Invalid secret')) { + res.status(400).json({ error: 'Invalid admin secret key.' }); + return; + } + + if (msg.includes('not found')) { + res.status(404).json({ error: msg }); + return; + } + + if (msg.includes('Cannot execute') || msg.includes('cannot be cancelled')) { + res.status(409).json({ error: msg }); + return; + } + + console.error('ContractUpgradeController unhandled error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +} diff --git a/backend/src/controllers/employeeController.ts b/backend/src/controllers/employeeController.ts index 0778ad7d..c63bb47b 100644 --- a/backend/src/controllers/employeeController.ts +++ b/backend/src/controllers/employeeController.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; -import { employeeService } from '../services/employeeService'; +import { employeeService } from '../services/employeeService.js'; import { createEmployeeSchema, updateEmployeeSchema, employeeQuerySchema, -} from '../schemas/employeeSchema'; +} from '../schemas/employeeSchema.js'; import { z } from 'zod'; export class EmployeeController { @@ -23,7 +23,7 @@ export class EmployeeController { res.status(201).json(employee); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: 'Validation Error', details: error.errors }); + res.status(400).json({ error: 'Validation Error', details: error.issues }); } else { console.error('Create Employee Error:', error); res.status(500).json({ error: 'Internal Server Error' }); @@ -43,7 +43,7 @@ export class EmployeeController { res.json(result); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: 'Validation Error', details: error.errors }); + res.status(400).json({ error: 'Validation Error', details: error.issues }); } else { console.error('Get All Employees Error:', error); res.status(500).json({ error: 'Internal Server Error' }); @@ -58,7 +58,7 @@ export class EmployeeController { return res.status(403).json({ error: 'User is not associated with an organization' }); } - const id = parseInt(req.params.id); + const id = parseInt(req.params.id as string); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ID' }); } @@ -82,7 +82,7 @@ export class EmployeeController { return res.status(403).json({ error: 'User is not associated with an organization' }); } - const id = parseInt(req.params.id); + const id = parseInt(req.params.id as string); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ID' }); } @@ -97,7 +97,7 @@ export class EmployeeController { res.json(employee); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: 'Validation Error', details: error.errors }); + res.status(400).json({ error: 'Validation Error', details: error.issues }); } else { console.error('Update Employee Error:', error); res.status(500).json({ error: 'Internal Server Error' }); @@ -112,7 +112,7 @@ export class EmployeeController { return res.status(403).json({ error: 'User is not associated with an organization' }); } - const id = parseInt(req.params.id); + const id = parseInt(req.params.id as string); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ID' }); } diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index c2b1fc60..ab32c67d 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; -import { ExportService } from '../services/exportService'; -import { payrollQueryService } from '../services/payroll-query.service'; -import logger from '../utils/logger'; +import { ExportService } from '../services/exportService.js'; +import { payrollQueryService } from '../services/payroll-query.service.js'; +import logger from '../utils/logger.js'; export class ExportController { /** @@ -11,7 +11,7 @@ export class ExportController { try { const { txHash } = req.params; - const transaction = await payrollQueryService.getTransactionDetails(txHash); + const transaction = await payrollQueryService.getTransactionDetails(txHash as string); if (!transaction) { res.status(404).json({ success: false, error: 'Transaction not found' }); return; @@ -20,7 +20,7 @@ export class ExportController { res.setHeader('Content-Type', 'application/pdf'); res.setHeader( 'Content-Disposition', - `attachment; filename="receipt-${txHash.substring(0, 8)}.pdf"` + `attachment; filename="receipt-${(txHash as string).substring(0, 8)}.pdf"` ); await ExportService.generateReceiptPdf(transaction, res); @@ -49,8 +49,8 @@ export class ExportController { // Assuming getPayrollBatch returns a paginated result, we might need a way to fetch all, // but for this implementation, we'll fetch the first massive page or assume limit handles it. const batchData = await payrollQueryService.getPayrollBatch( - organizationPublicKey, - batchId, + organizationPublicKey as string, + batchId as string, 1, 100000 ); @@ -66,7 +66,7 @@ export class ExportController { ); res.setHeader('Content-Disposition', `attachment; filename="payroll-batch-${batchId}.xlsx"`); - await ExportService.generatePayrollExcel(batchId, batchData.data, res); + await ExportService.generatePayrollExcel((batchId as string), batchData.data, res); } catch (error) { logger.error('Failed to generate Excel report', { error }); @@ -88,8 +88,8 @@ export class ExportController { const { organizationPublicKey, batchId } = req.params; const batchData = await payrollQueryService.getPayrollBatch( - organizationPublicKey, - batchId, + organizationPublicKey as string, + batchId as string, 1, 100000 ); diff --git a/backend/src/controllers/freezeController.ts b/backend/src/controllers/freezeController.ts index 8b29c42f..c14312d1 100644 --- a/backend/src/controllers/freezeController.ts +++ b/backend/src/controllers/freezeController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { Keypair } from '@stellar/stellar-sdk'; -import { FreezeService, FreezeAction } from '../services/freezeService'; +import { FreezeService, FreezeAction } from '../services/freezeService.js'; // --------------------------------------------------------------------------- // Validation schemas (defined once at module scope — O(1) memory cost) @@ -276,7 +276,7 @@ export class FreezeController { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Validation Error', - details: error.errors, + details: error.issues, }); return; } diff --git a/backend/src/controllers/healthController.ts b/backend/src/controllers/healthController.ts index 3cff238e..0f89e76b 100644 --- a/backend/src/controllers/healthController.ts +++ b/backend/src/controllers/healthController.ts @@ -1,8 +1,8 @@ import { Request, Response } from 'express'; import pg from 'pg'; -import Redis from 'ioredis'; -import { config } from '../config/env'; -import { StellarService } from '../services/stellarService'; +import { Redis } from 'ioredis'; +import { config } from '../config/env.js'; +import { StellarService } from '../services/stellarService.js'; const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); diff --git a/backend/src/controllers/multiSigController.ts b/backend/src/controllers/multiSigController.ts index 89d9fa19..607aecf0 100644 --- a/backend/src/controllers/multiSigController.ts +++ b/backend/src/controllers/multiSigController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { Keypair } from '@stellar/stellar-sdk'; -import { MultiSigService } from '../services/multiSigService'; -import logger from '../utils/logger'; +import { MultiSigService } from '../services/multiSigService.js'; +import logger from '../utils/logger.js'; export class MultiSigController { /** @@ -41,7 +41,7 @@ export class MultiSigController { static async getStatus(req: Request, res: Response): Promise { try { const { publicKey } = req.params; - const status = await MultiSigService.getMultiSigStatus(publicKey); + const status = await MultiSigService.getMultiSigStatus(publicKey as string); res.status(200).json({ success: true, data: status }); } catch (error: any) { logger.error('Failed to get multi-sig status', { error: error.message }); @@ -93,7 +93,7 @@ export class MultiSigController { } const issuerKeypair = Keypair.fromSecret(issuerSecret); - const result = await MultiSigService.removeIssuerSigner(issuerKeypair, publicKey); + const result = await MultiSigService.removeIssuerSigner(issuerKeypair, publicKey as string); res.status(200).json({ success: true, data: result }); } catch (error: any) { diff --git a/backend/src/controllers/payrollAuditController.ts b/backend/src/controllers/payrollAuditController.ts index ab8ab9b1..c1c4dbaa 100644 --- a/backend/src/controllers/payrollAuditController.ts +++ b/backend/src/controllers/payrollAuditController.ts @@ -4,8 +4,8 @@ import { AuditLogFilter, PayrollAuditAction, ActorType, -} from '../services/payrollAuditService'; -import logger from '../utils/logger'; +} from '../services/payrollAuditService.js'; +import logger from '../utils/logger.js'; export class PayrollAuditController { static async getAuditLogs(req: Request, res: Response): Promise { @@ -90,7 +90,7 @@ export class PayrollAuditController { static async getAuditLogById(req: Request, res: Response): Promise { try { const { id } = req.params; - const log = await PayrollAuditService.getAuditLogById(parseInt(id, 10)); + const log = await PayrollAuditService.getAuditLogById(parseInt(id as string, 10)); if (!log) { res.status(404).json({ error: 'Audit log not found' }); @@ -213,7 +213,7 @@ export class PayrollAuditController { const { page, limit } = req.query; const result = await PayrollAuditService.getAuditLogs( - { payrollRunId: parseInt(payrollRunId, 10) }, + { payrollRunId: parseInt(payrollRunId as string, 10) }, parseInt(page as string, 10) || 1, parseInt(limit as string, 10) || 50 ); @@ -243,7 +243,7 @@ export class PayrollAuditController { const { page, limit, startDate, endDate } = req.query; const filter: AuditLogFilter = { - employeeId: parseInt(employeeId, 10), + employeeId: parseInt(employeeId as string, 10), }; if (startDate) { diff --git a/backend/src/controllers/payrollBonusController.ts b/backend/src/controllers/payrollBonusController.ts index ff4873e1..1710104f 100644 --- a/backend/src/controllers/payrollBonusController.ts +++ b/backend/src/controllers/payrollBonusController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; -import { PayrollBonusService } from '../services/payrollBonusService'; -import logger from '../utils/logger'; +import { PayrollBonusService } from '../services/payrollBonusService.js'; +import logger from '../utils/logger.js'; export class PayrollBonusController { static async createPayrollRun(req: Request, res: Response): Promise { @@ -37,7 +37,7 @@ export class PayrollBonusController { static async getPayrollRun(req: Request, res: Response): Promise { try { const { id } = req.params; - const summary = await PayrollBonusService.getPayrollRunSummary(parseInt(id, 10)); + const summary = await PayrollBonusService.getPayrollRunSummary(parseInt(id as string, 10)); if (!summary) { res.status(404).json({ error: 'Payroll run not found' }); @@ -167,7 +167,7 @@ export class PayrollBonusController { const { itemType } = req.query; const items = await PayrollBonusService.getPayrollItems( - parseInt(payrollRunId, 10), + parseInt(payrollRunId as string, 10), itemType as 'base' | 'bonus' | undefined ); @@ -188,7 +188,7 @@ export class PayrollBonusController { static async deletePayrollItem(req: Request, res: Response): Promise { try { const { itemId } = req.params; - const deleted = await PayrollBonusService.deletePayrollItem(parseInt(itemId, 10)); + const deleted = await PayrollBonusService.deletePayrollItem(parseInt(itemId as string, 10)); if (!deleted) { res.status(404).json({ error: 'Payroll item not found' }); @@ -220,7 +220,7 @@ export class PayrollBonusController { return; } - const payrollRun = await PayrollBonusService.updatePayrollRunStatus(parseInt(id, 10), status); + const payrollRun = await PayrollBonusService.updatePayrollRunStatus(parseInt(id as string, 10), status); if (!payrollRun) { res.status(404).json({ error: 'Payroll run not found' }); diff --git a/backend/src/controllers/rateLimitController.ts b/backend/src/controllers/rateLimitController.ts index 5f661cb7..325790f4 100644 --- a/backend/src/controllers/rateLimitController.ts +++ b/backend/src/controllers/rateLimitController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { rateLimitService, RateLimitTierName } from '../services/rateLimitService'; +import { rateLimitService, RateLimitTierName } from '../services/rateLimitService.js'; export class RateLimitController { static async getStatus(req: Request, res: Response): Promise { diff --git a/backend/src/controllers/searchController.ts b/backend/src/controllers/searchController.ts index 0fac01af..92cefb6b 100644 --- a/backend/src/controllers/searchController.ts +++ b/backend/src/controllers/searchController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import searchService, { SearchFilters } from '../services/searchService'; +import searchService, { SearchFilters } from '../services/searchService.js'; import { z } from 'zod'; const searchQuerySchema = z.object({ @@ -37,7 +37,7 @@ const searchQuerySchema = z.object({ export class SearchController { async searchEmployees(req: Request, res: Response): Promise { try { - const organizationId = parseInt(req.params.organizationId, 10); + const organizationId = parseInt(req.params.organizationId as string, 10); if (isNaN(organizationId) || organizationId < 0) { res.status(400).json({ error: 'Invalid organization ID' }); @@ -50,7 +50,7 @@ export class SearchController { res.json(result); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: 'Invalid query parameters', details: error.errors }); + res.status(400).json({ error: 'Invalid query parameters', details: error.issues }); return; } console.error('Error searching employees:', error); @@ -60,7 +60,7 @@ export class SearchController { async searchTransactions(req: Request, res: Response): Promise { try { - const organizationId = parseInt(req.params.organizationId, 10); + const organizationId = parseInt(req.params.organizationId as string, 10); if (isNaN(organizationId) || organizationId < 0) { res.status(400).json({ error: 'Invalid organization ID' }); @@ -73,7 +73,7 @@ export class SearchController { res.json(result); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: 'Invalid query parameters', details: error.errors }); + res.status(400).json({ error: 'Invalid query parameters', details: error.issues }); return; } console.error('Error searching transactions:', error); diff --git a/backend/src/controllers/taxController.ts b/backend/src/controllers/taxController.ts index 85ddb233..75a6d244 100644 --- a/backend/src/controllers/taxController.ts +++ b/backend/src/controllers/taxController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { TaxService } from '../services/taxService'; +import { TaxService } from '../services/taxService.js'; const taxService = new TaxService(); diff --git a/backend/src/controllers/throttlingController.ts b/backend/src/controllers/throttlingController.ts index 24052cba..e5828a02 100644 --- a/backend/src/controllers/throttlingController.ts +++ b/backend/src/controllers/throttlingController.ts @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { ThrottlingService, ThrottlingConfig } from '../services/throttlingService'; +import { ThrottlingService, ThrottlingConfig } from '../services/throttlingService.js'; export class ThrottlingController { static getStatus(req: Request, res: Response): void { diff --git a/backend/src/controllers/transactionAuditController.ts b/backend/src/controllers/transactionAuditController.ts index 286695d7..e61f0f18 100644 --- a/backend/src/controllers/transactionAuditController.ts +++ b/backend/src/controllers/transactionAuditController.ts @@ -22,7 +22,7 @@ export class TransactionAuditController { static async createAuditRecord(req: Request, res: Response) { try { const { txHash } = req.params; - if (!txHash || txHash.length !== 64) { + if (!txHash || (txHash as string).length !== 64) { return res.status(400).json({ error: 'Invalid transaction hash.' }); } diff --git a/backend/src/controllers/trustlineController.ts b/backend/src/controllers/trustlineController.ts index a266f618..b3d24aba 100644 --- a/backend/src/controllers/trustlineController.ts +++ b/backend/src/controllers/trustlineController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { z } from 'zod'; -import { TrustlineService } from '../services/trustlineService'; +import { TrustlineService } from '../services/trustlineService.js'; const checkTrustlineSchema = z.object({ assetIssuer: z.string().length(56), @@ -22,7 +22,7 @@ export class TrustlineController { const { walletAddress } = req.params; const { assetIssuer } = checkTrustlineSchema.parse(req.query); - const result = await TrustlineService.checkTrustline(walletAddress, 'ORGUSD', assetIssuer); + const result = await TrustlineService.checkTrustline(walletAddress as string, 'ORGUSD', assetIssuer); res.json({ walletAddress, @@ -33,7 +33,7 @@ export class TrustlineController { }); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.errors }); + return res.status(400).json({ error: 'Validation Error', details: error.issues }); } console.error('Check Trustline Error:', error); res.status(500).json({ error: 'Failed to check trustline status.' }); @@ -46,7 +46,7 @@ export class TrustlineController { */ static async getEmployeeStatus(req: Request, res: Response) { try { - const employeeId = parseInt(req.params.employeeId); + const employeeId = parseInt(req.params.employeeId as string, 10); if (isNaN(employeeId)) { return res.status(400).json({ error: 'Invalid employee ID.' }); } @@ -73,7 +73,7 @@ export class TrustlineController { */ static async refreshEmployee(req: Request, res: Response) { try { - const employeeId = parseInt(req.params.employeeId); + const employeeId = parseInt(req.params.employeeId as string, 10); if (isNaN(employeeId)) { return res.status(400).json({ error: 'Invalid employee ID.' }); } @@ -89,7 +89,7 @@ export class TrustlineController { res.json(record); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.errors }); + return res.status(400).json({ error: 'Validation Error', details: error.issues }); } console.error('Refresh Trustline Error:', error); res.status(500).json({ error: 'Failed to refresh trustline status.' }); @@ -119,7 +119,7 @@ export class TrustlineController { }); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ error: 'Validation Error', details: error.errors }); + return res.status(400).json({ error: 'Validation Error', details: error.issues }); } console.error('Prompt Trustline Error:', error); res.status(500).json({ error: 'Failed to build trustline transaction.' }); diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index e29433e1..6efea553 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -20,7 +20,7 @@ export class WebhookController { res.status(201).json(subscription); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: error.errors }); + res.status(400).json({ error: error.issues }); return; } res.status(500).json({ error: 'Internal Server Error' }); @@ -34,7 +34,7 @@ export class WebhookController { static deleteSubscription(req: Request, res: Response) { const { id } = req.params; - const success = WebhookService.deleteSubscription(id); + const success = WebhookService.deleteSubscription(id as string); if (success) { res.status(204).send(); return; @@ -46,7 +46,7 @@ export class WebhookController { static async triggerMockEvent(req: Request, res: Response) { const { event, payload } = req.body; await WebhookService.dispatch( - event || 'payment.completed', + (event as string) || 'payment.completed', payload || { id: 'test_tx_123', amount: 100 } ); res.json({ message: 'Mock event dispatched' }); diff --git a/backend/src/db/migrations/014_create_contract_registry.sql b/backend/src/db/migrations/014_create_contract_registry.sql new file mode 100644 index 00000000..554660d5 --- /dev/null +++ b/backend/src/db/migrations/014_create_contract_registry.sql @@ -0,0 +1,198 @@ +-- ============================================================================= +-- Migration 014: Contract Registry & Upgrade Logs +-- Purpose : Persist the set of deployed Soroban contracts and a full, +-- append-only history of every upgrade attempt. +-- +-- Design decisions: +-- • contract_registry is the source-of-truth for deployed contract state. +-- contract_id is the on-chain C-address; current_wasm_hash is the +-- SHA-256 of the live WASM module (64 hex chars). +-- • contract_upgrade_logs is append-only: no DELETE/UPDATE policy beyond +-- status transitions, mirroring the audit_logs pattern. +-- • status uses a CHECK constraint (not PG ENUM) for zero-downtime +-- extension — adding a new status requires only a migration, not an +-- ALTER TYPE that briefly acquires a table lock. +-- • migration_steps stores an ordered JSON array of +-- { id, name, status, message } objects so progress is queryable +-- without a separate table, keeping O(1) row-level access. +-- • BRIN on created_at (upgrade_logs): upgrades are infrequent and +-- monotonically timestamped; BRIN keeps index size ~1000× smaller +-- than B-tree for this access pattern. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- contract_registry — one row per deployed Soroban contract +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS contract_registry ( + id SERIAL PRIMARY KEY, + + -- Human-readable label shown in the admin UI. + name VARCHAR(100) NOT NULL UNIQUE, + + -- Optional description for the contract's purpose. + description TEXT, + + -- Which Stellar network this entry belongs to. + network VARCHAR(20) NOT NULL DEFAULT 'TESTNET' + CHECK (network IN ('TESTNET', 'MAINNET')), + + -- On-chain contract address (C... bech32m address). + contract_id VARCHAR(255) NOT NULL UNIQUE, + + -- SHA-256 of the currently deployed WASM module (64 lowercase hex chars). + -- Updated atomically after a successful upgrade. + current_wasm_hash VARCHAR(64) NOT NULL + CHECK (current_wasm_hash ~ '^[0-9a-f]{64}$'), + + -- Semantic version tag — informational only, not enforced by DB. + version VARCHAR(50) NOT NULL DEFAULT '1.0.0', + + -- Timestamp and actor of the last successful upgrade. NULL on first deploy. + last_upgraded_at TIMESTAMPTZ, + last_upgraded_by VARCHAR(255), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index: admin UI lists contracts filtered by network +CREATE INDEX IF NOT EXISTS idx_contract_registry_network + ON contract_registry (network, created_at DESC); + +-- --------------------------------------------------------------------------- +-- contract_upgrade_logs — append-only record of every upgrade attempt +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS contract_upgrade_logs ( + id BIGSERIAL PRIMARY KEY, + + -- FK to the contract being upgraded. ON DELETE CASCADE so orphan logs + -- are cleaned up if a contract is removed from the registry. + registry_id INTEGER NOT NULL + REFERENCES contract_registry(id) ON DELETE CASCADE, + + -- Snapshot of the hashes at time of initiation. + previous_wasm_hash VARCHAR(64) NOT NULL + CHECK (previous_wasm_hash ~ '^[0-9a-f]{64}$'), + new_wasm_hash VARCHAR(64) NOT NULL + CHECK (new_wasm_hash ~ '^[0-9a-f]{64}$'), + + -- Lifecycle status — transitions are one-directional: + -- pending → simulated → confirmed → executing → completed + -- → failed + -- pending/simulated → cancelled + status VARCHAR(30) NOT NULL DEFAULT 'pending' + CHECK (status IN ( + 'pending', 'simulated', 'confirmed', + 'executing', 'completed', 'failed', 'cancelled' + )), + + -- JSONB blob from the Soroban RPC simulateTransaction response. + simulation_result JSONB, + + -- On-chain transaction hash after successful execution. + tx_hash VARCHAR(255), + + -- Ordered array of post-upgrade migration steps. + -- Shape: [{ id: string, name: string, status: "pending"|"running"|"completed"|"failed", message: string|null }] + migration_steps JSONB NOT NULL DEFAULT '[]', + + -- Who triggered this upgrade attempt (admin wallet address). + initiated_by VARCHAR(255) NOT NULL, + + -- Optional notes / changelog for this upgrade. + notes TEXT, + + -- Human-readable error captured on failure. + error_message TEXT, + + -- Immutable timestamp when the attempt was created. + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Timestamp when the attempt reached a terminal state (completed/failed). + completed_at TIMESTAMPTZ, + + -- Constraint: new hash must differ from previous hash. + CONSTRAINT chk_different_hashes + CHECK (new_wasm_hash <> previous_wasm_hash) +); + +-- --------------------------------------------------------------------------- +-- Indexes on contract_upgrade_logs +-- --------------------------------------------------------------------------- + +-- Primary access pattern: "show all upgrades for contract X" +-- O(log n); covers the per-contract history list endpoint. +CREATE INDEX IF NOT EXISTS idx_upgrade_logs_registry_id + ON contract_upgrade_logs (registry_id, created_at DESC); + +-- Status-filtered queries: "show all in-progress upgrades" +-- Partial index — only active states; completed/failed rows excluded. +CREATE INDEX IF NOT EXISTS idx_upgrade_logs_active_status + ON contract_upgrade_logs (status, created_at DESC) + WHERE status IN ('pending', 'simulated', 'confirmed', 'executing'); + +-- BRIN on created_at: upgrades are rare & monotonically timestamped. +-- ~1000× smaller than B-tree for this write pattern. +CREATE INDEX IF NOT EXISTS idx_upgrade_logs_created_at_brin + ON contract_upgrade_logs USING BRIN (created_at) + WITH (pages_per_range = 128); + +-- --------------------------------------------------------------------------- +-- Seed: register the contracts already in this project's /contracts dir. +-- WASM hashes are placeholder values — replace with real hashes post-deploy. +-- --------------------------------------------------------------------------- +INSERT INTO contract_registry + (name, description, network, contract_id, current_wasm_hash, version) +VALUES + ( + 'Bulk Payment', + 'Executes batch payroll payments to multiple recipients in a single transaction.', + 'TESTNET', + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '1.0.0' + ), + ( + 'Cross Asset Payment', + 'Atomic cross-asset swap and payment routing between different Stellar assets.', + 'TESTNET', + 'CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC4', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + '1.0.0' + ), + ( + 'Revenue Split', + 'Distributes revenue among multiple recipients according to configurable split ratios.', + 'TESTNET', + 'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCBSC4', + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + '1.0.0' + ), + ( + 'Vesting Escrow', + 'Time-locked fund release with configurable vesting schedules for employee compensation.', + 'TESTNET', + 'CDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDCSC4', + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + '1.0.0' + ) +ON CONFLICT (name) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Comments +-- --------------------------------------------------------------------------- +COMMENT ON TABLE contract_registry IS + 'Source-of-truth registry of deployed Soroban smart contracts. ' + 'current_wasm_hash is updated atomically after each successful upgrade.'; + +COMMENT ON TABLE contract_upgrade_logs IS + 'Append-only audit trail of every contract upgrade attempt. ' + 'Rows are never deleted. Status transitions forward only.'; + +COMMENT ON COLUMN contract_upgrade_logs.migration_steps IS + 'JSON array of post-upgrade data migration steps. ' + 'Shape: [{id, name, status: pending|running|completed|failed, message}]. ' + 'Updated in-place as migration progresses (single JSONB column update).'; + +COMMENT ON COLUMN contract_upgrade_logs.simulation_result IS + 'Raw Soroban RPC simulateTransaction result stored for auditability. ' + 'Contains estimated fees, resource usage, and any simulation errors.'; diff --git a/backend/src/db/migrations/015_create_contract_events.sql b/backend/src/db/migrations/015_create_contract_events.sql new file mode 100644 index 00000000..d2773215 --- /dev/null +++ b/backend/src/db/migrations/015_create_contract_events.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS contract_events ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + ledger_sequence BIGINT NOT NULL, + tx_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_events_event_id + ON contract_events (event_id, contract_id); + +CREATE INDEX IF NOT EXISTS idx_contract_events_contract_ledger + ON contract_events (contract_id, ledger_sequence DESC); + +CREATE TABLE IF NOT EXISTS contract_event_index_state ( + id BIGSERIAL PRIMARY KEY, + state_key TEXT NOT NULL UNIQUE, + last_ledger_sequence BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/db/migrations/017_add_timezone_to_schedules.sql b/backend/src/db/migrations/017_add_timezone_to_schedules.sql new file mode 100644 index 00000000..db0524c7 --- /dev/null +++ b/backend/src/db/migrations/017_add_timezone_to_schedules.sql @@ -0,0 +1,2 @@ +-- Add timezone column to schedules table +ALTER TABLE schedules ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC' NOT NULL; diff --git a/backend/src/db/migrations/017_create_claimable_balances.sql b/backend/src/db/migrations/017_create_claimable_balances.sql deleted file mode 100644 index 66c09fb6..00000000 --- a/backend/src/db/migrations/017_create_claimable_balances.sql +++ /dev/null @@ -1,44 +0,0 @@ --- ============================================================================= --- Migration 017: Claimable balances + custodial wallet secrets --- Purpose : Track claimable balances for employees who cannot receive ORGUSD --- and store custodial wallet secrets (encrypted at rest). --- ============================================================================= - -CREATE TABLE IF NOT EXISTS custodial_wallet_secrets ( - wallet_id UUID PRIMARY KEY REFERENCES wallets(id) ON DELETE CASCADE, - encrypted_secret TEXT NOT NULL, - encryption_version INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS claimable_balance_claims ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - employee_id INTEGER REFERENCES employees(id) ON DELETE SET NULL, - claimant_wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL, - - asset_code VARCHAR(12) NOT NULL DEFAULT 'ORGUSD', - asset_issuer VARCHAR(56) NOT NULL, - amount DECIMAL(20, 7) NOT NULL CHECK (amount > 0), - - status VARCHAR(20) NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'claimed', 'expired', 'cancelled')), - - stellar_balance_id VARCHAR(128), - create_tx_hash VARCHAR(64), - claim_tx_hash VARCHAR(64), - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - claimed_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_cb_claims_org_id ON claimable_balance_claims(organization_id); -CREATE INDEX IF NOT EXISTS idx_cb_claims_employee_id ON claimable_balance_claims(employee_id); -CREATE INDEX IF NOT EXISTS idx_cb_claims_status ON claimable_balance_claims(status); -CREATE INDEX IF NOT EXISTS idx_cb_claims_balance_id ON claimable_balance_claims(stellar_balance_id); - -CREATE TRIGGER update_claimable_balance_claims_updated_at - BEFORE UPDATE ON claimable_balance_claims - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/middleware/__tests__/tenantContext.test.ts b/backend/src/middleware/__tests__/tenantContext.test.ts index cc6628fb..ba141506 100644 --- a/backend/src/middleware/__tests__/tenantContext.test.ts +++ b/backend/src/middleware/__tests__/tenantContext.test.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; -import { extractTenantId, validateTenant, setTenantContext } from '../tenantContext'; -import { pool } from '../../config/database'; +import { extractTenantId, validateTenant, setTenantContext } from '../tenantContext.js'; +import { pool } from '../../config/database.js'; // Mock the database pool jest.mock('../../config/database', () => ({ diff --git a/backend/src/middleware/tenantContext.ts b/backend/src/middleware/tenantContext.ts index 044ffb0b..f46ea7bb 100644 --- a/backend/src/middleware/tenantContext.ts +++ b/backend/src/middleware/tenantContext.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { pool } from '../config/database'; +import { pool } from '../config/database.js'; // Extend Express Request to include tenant information declare global { @@ -23,15 +23,19 @@ export const extractTenantId = (req: Request, res: Response, next: NextFunction) // Method 1: Extract from URL parameters if (req.params.organizationId) { - tenantId = parseInt(req.params.organizationId, 10); + tenantId = parseInt(req.params.organizationId as string, 10); } // Method 2: Extract from headers (useful for non-RESTful endpoints) if (!tenantId && req.headers['x-organization-id']) { const headerValue = req.headers['x-organization-id']; - tenantId = parseInt(Array.isArray(headerValue) ? headerValue[0] : headerValue, 10); + const headerValStr = Array.isArray(headerValue) ? headerValue[0] : headerValue; + if (headerValStr) { + tenantId = parseInt(headerValStr as string, 10); + } } + // Method 3: Extract from JWT token (placeholder for future auth implementation) // if (!tenantId && req.user?.organizationId) { // tenantId = req.user.organizationId; diff --git a/backend/src/middlewares/apiVersionMiddleware.ts b/backend/src/middlewares/apiVersionMiddleware.ts index 5c5ecc60..d5d567a7 100644 --- a/backend/src/middlewares/apiVersionMiddleware.ts +++ b/backend/src/middlewares/apiVersionMiddleware.ts @@ -1,5 +1,5 @@ import express, { Request, Response, NextFunction } from 'express'; -import logger from '../utils/logger'; +import logger from '../utils/logger.js'; export type ApiVersion = 'v1'; diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index ae1e8fc8..1a11253f 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -1,50 +1,32 @@ -import { Router } from 'express'; -import searchController from '../controllers/searchController'; -import authenticateJWT from '../middlewares/auth'; -import { isolateOrganization } from '../middlewares/rbac'; -import { requireTenantContext } from '../middleware/tenantContext'; - -const router = Router(); - -router.use(authenticateJWT); -router.use(isolateOrganization); +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../config/env.js'; +import { JWTPayload } from '../types/auth.js'; /** - * @route GET /api/search/organizations/:organizationId/employees - * @desc Search and filter employees - * @query query - Full-text search query - * @query status - Comma-separated status values (active,inactive,pending) - * @query dateFrom - Start date (ISO 8601) - * @query dateTo - End date (ISO 8601) - * @query page - Page number (default: 1) - * @query limit - Items per page (default: 20) - * @query sortBy - Sort column (created_at, first_name, last_name, email, status) - * @query sortOrder - Sort order (asc, desc) + * Middleware to authenticate requests using JWT */ -router.get( - '/organizations/:organizationId/employees', - requireTenantContext, - searchController.searchEmployees.bind(searchController) -); +export const authenticateJWT = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; -/** - * @route GET /api/search/organizations/:organizationId/transactions - * @desc Search and filter transactions - * @query query - Full-text search query (tx_hash, asset_code) - * @query status - Comma-separated status values (pending,completed,failed) - * @query dateFrom - Start date (ISO 8601) - * @query dateTo - End date (ISO 8601) - * @query amountMin - Minimum amount - * @query amountMax - Maximum amount - * @query page - Page number (default: 1) - * @query limit - Items per page (default: 20) - * @query sortBy - Sort column (created_at, amount, status, tx_hash) - * @query sortOrder - Sort order (asc, desc) - */ -router.get( - '/organizations/:organizationId/transactions', - requireTenantContext, - searchController.searchTransactions.bind(searchController) -); + if (authHeader) { + const token = authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Authentication token missing' }); + } + + try { + const decoded = jwt.verify(token, config.JWT_SECRET) as JWTPayload; + req.user = decoded; + next(); + } catch (error) { + console.error('JWT verification failed:', error); + return res.status(403).json({ error: 'Invalid or expired token' }); + } + } else { + res.status(401).json({ error: 'Authorization header missing' }); + } +}; -export default router; +export default authenticateJWT; diff --git a/backend/src/middlewares/rateLimitMiddleware.ts b/backend/src/middlewares/rateLimitMiddleware.ts index 726fbb2a..ec9149ec 100644 --- a/backend/src/middlewares/rateLimitMiddleware.ts +++ b/backend/src/middlewares/rateLimitMiddleware.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; -import { rateLimitService, RateLimitTierName } from '../services/rateLimitService'; -import logger from '../utils/logger'; +import { rateLimitService, RateLimitTierName } from '../services/rateLimitService.js'; +import logger from '../utils/logger.js'; export interface RateLimitOptions { tier?: RateLimitTierName; @@ -12,12 +12,13 @@ export interface RateLimitOptions { function defaultIdentifier(req: Request): string { return ( req.ip || - req.headers['x-forwarded-for']?.toString().split(',')[0].trim() || + req.headers['x-forwarded-for']?.toString().split(',')[0]?.trim() || req.headers['x-real-ip']?.toString() || 'unknown' ); } + function defaultHandler(_req: Request, res: Response, result: any): void { res.status(429).json({ error: 'Too Many Requests', diff --git a/backend/src/middlewares/rbac.ts b/backend/src/middlewares/rbac.ts index a3acfb77..bed4d01f 100644 --- a/backend/src/middlewares/rbac.ts +++ b/backend/src/middlewares/rbac.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; -import { UserRole } from '../types/auth'; +import { UserRole } from '../types/auth.js'; import { Pool } from 'pg'; -import { config } from '../config/env'; +import { config } from '../config/env.js'; const pool = new Pool({ connectionString: config.DATABASE_URL }); @@ -11,7 +11,7 @@ export const authorizeRoles = (...roles: UserRole[]) => { return res.status(401).json({ error: 'User not authenticated' }); } - if (!roles.includes(req.user.role)) { + if (!roles.includes(req.user.role as UserRole)) { return res.status(403).json({ error: 'Access denied: Insufficient permissions' }); } diff --git a/backend/src/middlewares/require2fa.ts b/backend/src/middlewares/require2fa.ts index 7ca77d25..e4051e48 100644 --- a/backend/src/middlewares/require2fa.ts +++ b/backend/src/middlewares/require2fa.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { authenticator } from '@otplib/preset-default'; import pg from 'pg'; -import { config } from '../config/env'; +import { config } from '../config/env.js'; const pool = new pg.Pool({ connectionString: config.DATABASE_URL }); diff --git a/backend/src/middlewares/throttlingMiddleware.ts b/backend/src/middlewares/throttlingMiddleware.ts index 92eb3948..31198fd6 100644 --- a/backend/src/middlewares/throttlingMiddleware.ts +++ b/backend/src/middlewares/throttlingMiddleware.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { ThrottlingService } from '../services/throttlingService'; +import { ThrottlingService } from '../services/throttlingService.js'; export interface ThrottlingMiddlewareOptions { priorityHeader?: string; diff --git a/backend/src/routes/assetRoutes.ts b/backend/src/routes/assetRoutes.ts index 45a26b29..470386d5 100644 --- a/backend/src/routes/assetRoutes.ts +++ b/backend/src/routes/assetRoutes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; -import { AssetController } from '../controllers/assetController'; -import { authenticateJWT } from '../middlewares/auth'; -import { authorizeRoles } from '../middlewares/rbac'; +import { AssetController } from '../controllers/assetController.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { authorizeRoles } from '../middlewares/rbac.js'; const router = Router(); diff --git a/backend/src/routes/balanceRoutes.ts b/backend/src/routes/balanceRoutes.ts index 62176a81..2a15753f 100644 --- a/backend/src/routes/balanceRoutes.ts +++ b/backend/src/routes/balanceRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { BalanceController } from '../controllers/balanceController'; +import { BalanceController } from '../controllers/balanceController.js'; const router = Router(); diff --git a/backend/src/routes/claimableBalanceRoutes.ts b/backend/src/routes/claimableBalanceRoutes.ts deleted file mode 100644 index 2e171b50..00000000 --- a/backend/src/routes/claimableBalanceRoutes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from 'express'; -import { ClaimableBalanceController } from '../controllers/claimableBalanceController.js'; - -const router = Router(); - -router.post('/create-for-employee', ClaimableBalanceController.createForEmployee); -router.get('/pending', ClaimableBalanceController.listPendingForWallet); - -export default router; diff --git a/backend/src/routes/contractRoutes.ts b/backend/src/routes/contractRoutes.ts new file mode 100644 index 00000000..638a73a2 --- /dev/null +++ b/backend/src/routes/contractRoutes.ts @@ -0,0 +1,17 @@ +/** + * Contract Routes + * Defines routes for the Contract Address Registry API + */ + +import { Router } from 'express'; +import { ContractController } from '../controllers/contractController.js'; + +const router = Router(); + +/** + * GET /contracts + * Returns all deployed contract addresses with metadata + */ +router.get('/contracts', ContractController.getContracts); + +export default router; diff --git a/backend/src/routes/contractUpgradeRoutes.ts b/backend/src/routes/contractUpgradeRoutes.ts new file mode 100644 index 00000000..61369074 --- /dev/null +++ b/backend/src/routes/contractUpgradeRoutes.ts @@ -0,0 +1,83 @@ +import { Router } from 'express'; +import { ContractUpgradeController } from '../controllers/contractUpgradeController.js'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Contract registry — list & detail +// --------------------------------------------------------------------------- + +/** GET /api/v1/contracts — list all registered contracts */ +router.get('/', (req, res) => void ContractUpgradeController.listContracts(req, res)); + +/** GET /api/v1/contracts/:registryId — single contract detail */ +router.get('/:registryId', (req, res) => void ContractUpgradeController.getContract(req, res)); + +// --------------------------------------------------------------------------- +// Per-contract upgrade lifecycle +// --------------------------------------------------------------------------- + +/** + * POST /api/v1/contracts/:registryId/validate-hash + * Body: { newWasmHash } + * Validates format + on-chain existence before simulation. + */ +router.post( + '/:registryId/validate-hash', + (req, res) => void ContractUpgradeController.validateHash(req, res) +); + +/** + * POST /api/v1/contracts/:registryId/simulate-upgrade + * Body: { newWasmHash, initiatedBy, notes? } + * Pre-flights the upgrade via Soroban RPC, returns cost estimate. + */ +router.post( + '/:registryId/simulate-upgrade', + (req, res) => void ContractUpgradeController.simulateUpgrade(req, res) +); + +/** + * GET /api/v1/contracts/:registryId/upgrade-logs + * Query: ?page=1&limit=20 + * Paginated upgrade history for a specific contract. + */ +router.get( + '/:registryId/upgrade-logs', + (req, res) => void ContractUpgradeController.listUpgradeLogs(req, res) +); + +// --------------------------------------------------------------------------- +// Upgrade log actions (logId-scoped, placed before /:registryId to avoid +// route ambiguity — Express matches in registration order) +// --------------------------------------------------------------------------- + +/** + * POST /api/v1/contracts/upgrade-logs/:logId/execute + * Body: { adminSecret } + * Executes a simulated upgrade on-chain and starts migration. + */ +router.post( + '/upgrade-logs/:logId/execute', + (req, res) => void ContractUpgradeController.executeUpgrade(req, res) +); + +/** + * GET /api/v1/contracts/upgrade-logs/:logId/status + * Polls migration step progress for an executing upgrade. + */ +router.get( + '/upgrade-logs/:logId/status', + (req, res) => void ContractUpgradeController.getUpgradeStatus(req, res) +); + +/** + * POST /api/v1/contracts/upgrade-logs/:logId/cancel + * Cancels a pending or simulated upgrade before execution. + */ +router.post( + '/upgrade-logs/:logId/cancel', + (req, res) => void ContractUpgradeController.cancelUpgrade(req, res) +); + +export default router; diff --git a/backend/src/routes/employeeRoutes.ts b/backend/src/routes/employeeRoutes.ts index c87e1879..dd173c8a 100644 --- a/backend/src/routes/employeeRoutes.ts +++ b/backend/src/routes/employeeRoutes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; -import { employeeController } from '../controllers/employeeController'; -import authenticateJWT from '../middlewares/auth'; -import { authorizeRoles, isolateOrganization } from '../middlewares/rbac'; +import { employeeController } from '../controllers/employeeController.js'; +import authenticateJWT from '../middlewares/auth.js'; +import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; const router = Router(); @@ -67,7 +67,7 @@ router.delete( * @route POST /api/employees/bulk-import * @desc Bulk import employees from CSV */ -import { bulkImportController } from '../controllers/bulkImportController'; +import { bulkImportController } from '../controllers/bulkImportController.js'; router.post('/bulk-import', bulkImportController.import.bind(bulkImportController)); export default router; diff --git a/backend/src/routes/exportRoutes.ts b/backend/src/routes/exportRoutes.ts index 50e97403..b6d8ad85 100644 --- a/backend/src/routes/exportRoutes.ts +++ b/backend/src/routes/exportRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { ExportController } from '../controllers/exportController'; +import { ExportController } from '../controllers/exportController.js'; const router = Router(); diff --git a/backend/src/routes/freezeRoutes.ts b/backend/src/routes/freezeRoutes.ts index 18a52027..9c50f32b 100644 --- a/backend/src/routes/freezeRoutes.ts +++ b/backend/src/routes/freezeRoutes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { FreezeController } from '../controllers/freezeController'; -import { rateLimitMiddleware } from '../middlewares/rateLimitMiddleware'; +import { FreezeController } from '../controllers/freezeController.js'; +import { rateLimitMiddleware } from '../middlewares/rateLimitMiddleware.js'; const router = Router(); diff --git a/backend/src/routes/multiSigRoutes.ts b/backend/src/routes/multiSigRoutes.ts index 05826971..6b21ec9d 100644 --- a/backend/src/routes/multiSigRoutes.ts +++ b/backend/src/routes/multiSigRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { MultiSigController } from '../controllers/multiSigController'; +import { MultiSigController } from '../controllers/multiSigController.js'; const router = Router(); diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index e8c67bba..7eb2b459 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -1,8 +1,8 @@ import { Request, Response, Router } from 'express'; -import { payrollQueryService } from '../services/payroll-query.service'; -import logger from '../utils/logger'; -import { authenticateJWT } from '../middlewares/auth'; -import { authorizeRoles, isolateOrganization } from '../middlewares/rbac'; +import { payrollQueryService } from '../services/payroll-query.service.js'; +import logger from '../utils/logger.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; const router = Router(); @@ -94,7 +94,7 @@ router.get('/employees/:employeeId', async (req: Request, res: Response) => { const result = await payrollQueryService.getEmployeePayroll( String(orgPublicKey), - employeeId, + employeeId as string, startDate ? new Date(String(startDate)) : undefined, endDate ? new Date(String(endDate)) : undefined, Number(page), @@ -131,7 +131,7 @@ router.get('/employees/:employeeId/summary', async (req: Request, res: Response) const summary = await payrollQueryService.getEmployeeSummary( String(orgPublicKey), - employeeId, + employeeId as string, startDate ? new Date(String(startDate)) : undefined, endDate ? new Date(String(endDate)) : undefined ); @@ -166,7 +166,7 @@ router.get('/batches/:batchId', async (req: Request, res: Response) => { const result = await payrollQueryService.getPayrollBatch( String(orgPublicKey), - batchId, + batchId as string, Number(page), Number(limit) ); @@ -294,7 +294,7 @@ router.get('/transactions/:txHash', async (req: Request, res: Response) => { try { const { txHash } = req.params; - const transaction = await payrollQueryService.getTransactionDetails(txHash); + const transaction = await payrollQueryService.getTransactionDetails(txHash as string); if (!transaction) { return res.status(404).json({ diff --git a/backend/src/routes/payrollAuditRoutes.ts b/backend/src/routes/payrollAuditRoutes.ts index a4687546..19b6cc7b 100644 --- a/backend/src/routes/payrollAuditRoutes.ts +++ b/backend/src/routes/payrollAuditRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { PayrollAuditController } from '../controllers/payrollAuditController'; +import { PayrollAuditController } from '../controllers/payrollAuditController.js'; const router = Router(); diff --git a/backend/src/routes/payrollBonusRoutes.ts b/backend/src/routes/payrollBonusRoutes.ts index 20428fc0..45567c10 100644 --- a/backend/src/routes/payrollBonusRoutes.ts +++ b/backend/src/routes/payrollBonusRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { PayrollBonusController } from '../controllers/payrollBonusController'; +import { PayrollBonusController } from '../controllers/payrollBonusController.js'; const router = Router(); diff --git a/backend/src/routes/rateLimitRoutes.ts b/backend/src/routes/rateLimitRoutes.ts index b2aa29fe..8ed7110e 100644 --- a/backend/src/routes/rateLimitRoutes.ts +++ b/backend/src/routes/rateLimitRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { RateLimitController } from '../controllers/rateLimitController'; +import { RateLimitController } from '../controllers/rateLimitController.js'; const router = Router(); diff --git a/backend/src/routes/searchRoutes.ts b/backend/src/routes/searchRoutes.ts index e07e1339..93ae7406 100644 --- a/backend/src/routes/searchRoutes.ts +++ b/backend/src/routes/searchRoutes.ts @@ -1,28 +1,18 @@ import { Router } from 'express'; -import searchController from '../controllers/searchController'; -import { authenticateJWT } from '../middlewares/auth'; -import { isolateOrganization } from '../middlewares/rbac'; -import { authenticateJWT } from '../middlewares/auth'; -import { isolateOrganization } from '../middlewares/rbac'; -import { requireTenantContext } from '../middleware/tenantContext'; - -import authenticateJWT from '../middlewares/auth'; -import { isolateOrganization } from '../middlewares/rbac'; -import { requireTenantContext } from '../middleware/tenantContext'; +import searchController from '../controllers/searchController.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { isolateOrganization } from '../middlewares/rbac.js'; +import { requireTenantContext } from '../middleware/tenantContext.js'; const router = Router(); +// Apply global authentication and isolation to all search routes +router.use(authenticateJWT); +router.use(isolateOrganization); + /** * @route GET /api/search/organizations/:organizationId/employees * @desc Search and filter employees - * @query query - Full-text search query - * @query status - Comma-separated status values (active,inactive,pending) - * @query dateFrom - Start date (ISO 8601) - * @query dateTo - End date (ISO 8601) - * @query page - Page number (default: 1) - * @query limit - Items per page (default: 20) - * @query sortBy - Sort column (created_at, first_name, last_name, email, status) - * @query sortOrder - Sort order (asc, desc) */ router.get( '/organizations/:organizationId/employees', @@ -33,16 +23,6 @@ router.get( /** * @route GET /api/search/organizations/:organizationId/transactions * @desc Search and filter transactions - * @query query - Full-text search query (tx_hash, asset_code) - * @query status - Comma-separated status values (pending,completed,failed) - * @query dateFrom - Start date (ISO 8601) - * @query dateTo - End date (ISO 8601) - * @query amountMin - Minimum amount - * @query amountMax - Maximum amount - * @query page - Page number (default: 1) - * @query limit - Items per page (default: 20) - * @query sortBy - Sort column (created_at, amount, status, tx_hash) - * @query sortOrder - Sort order (asc, desc) */ router.get( '/organizations/:organizationId/transactions', diff --git a/backend/src/routes/taxRoutes.ts b/backend/src/routes/taxRoutes.ts index 073df3a3..d6e97621 100644 --- a/backend/src/routes/taxRoutes.ts +++ b/backend/src/routes/taxRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { TaxController } from '../controllers/taxController'; +import { TaxController } from '../controllers/taxController.js'; const router = Router(); diff --git a/backend/src/routes/throttlingRoutes.ts b/backend/src/routes/throttlingRoutes.ts index 553897d6..34e0a13b 100644 --- a/backend/src/routes/throttlingRoutes.ts +++ b/backend/src/routes/throttlingRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { ThrottlingController } from '../controllers/throttlingController'; +import { ThrottlingController } from '../controllers/throttlingController.js'; const router = Router(); diff --git a/backend/src/routes/trustlineRoutes.ts b/backend/src/routes/trustlineRoutes.ts index 1b1c4cef..03d226bf 100644 --- a/backend/src/routes/trustlineRoutes.ts +++ b/backend/src/routes/trustlineRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { TrustlineController } from '../controllers/trustlineController'; +import { TrustlineController } from '../controllers/trustlineController.js'; const router = Router(); diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 497a842b..a0230bb9 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -18,7 +18,7 @@ import taxRoutes from '../taxRoutes.js'; import multiSigRoutes from '../multiSigRoutes.js'; import rateLimitRoutes from '../rateLimitRoutes.js'; import freezeRoutes from '../freezeRoutes.js'; -import claimableBalanceRoutes from '../claimableBalanceRoutes.js'; +import contractUpgradeRoutes from '../contractUpgradeRoutes.js'; const router = Router(); @@ -39,6 +39,6 @@ router.use('/taxes', dataRateLimit(), taxRoutes); router.use('/multisig', apiRateLimit(), multiSigRoutes); router.use('/rate-limit', apiRateLimit(), rateLimitRoutes); router.use('/freeze', apiRateLimit(), freezeRoutes); -router.use('/claims', apiRateLimit(), claimableBalanceRoutes); +router.use('/contracts', apiRateLimit(), contractUpgradeRoutes); export default router; diff --git a/backend/src/schemas/employeeSchema.ts b/backend/src/schemas/employeeSchema.ts index fc4a0ba6..93b049d8 100644 --- a/backend/src/schemas/employeeSchema.ts +++ b/backend/src/schemas/employeeSchema.ts @@ -16,8 +16,8 @@ export const createEmployeeSchema = z.object({ export const updateEmployeeSchema = createEmployeeSchema.partial().omit({ organization_id: true }); export const employeeQuerySchema = z.object({ - page: z.string().regex(/^\d+$/).transform(Number).optional().default('1'), - limit: z.string().regex(/^\d+$/).transform(Number).optional().default('10'), + page: z.string().regex(/^\d+$/).transform(Number).optional().default('1' as any), // Fix: mismatch between number and string default + limit: z.string().regex(/^\d+$/).transform(Number).optional().default('10' as any), // Fix: mismatch between number and string default search: z.string().optional(), status: z.enum(['active', 'inactive', 'pending']).optional(), department: z.string().optional(), diff --git a/backend/src/services/__tests__/contractConfigService.test.ts b/backend/src/services/__tests__/contractConfigService.test.ts new file mode 100644 index 00000000..815f6a5a --- /dev/null +++ b/backend/src/services/__tests__/contractConfigService.test.ts @@ -0,0 +1,100 @@ +/** + * Contract Config Service Tests + * Tests for parsing contract configuration from TOML and environment variables + */ + +import { ContractConfigService } from '../contractConfigService.js'; +import fs from 'fs'; +import path from 'path'; + +describe('ContractConfigService', () => { + describe('parseTomlConfig', () => { + it('should parse contracts from a valid TOML file', () => { + const service = new ContractConfigService('environments.toml'); + const entries = service.parseTomlConfig(); + + expect(entries.length).toBeGreaterThan(0); + + // Check that we have both testnet and mainnet contracts + const testnets = entries.filter(e => e.network === 'testnet'); + const mainnets = entries.filter(e => e.network === 'mainnet'); + + expect(testnets.length).toBeGreaterThan(0); + expect(mainnets.length).toBeGreaterThan(0); + }); + + it('should return empty array for non-existent file', () => { + const service = new ContractConfigService('non-existent.toml'); + const entries = service.parseTomlConfig(); + + expect(entries).toEqual([]); + }); + }); + + describe('parseEnvVarConfig', () => { + beforeEach(() => { + // Clear any existing contract env vars + Object.keys(process.env).forEach(key => { + if (key.includes('CONTRACT_ID')) { + delete process.env[key]; + } + }); + }); + + it('should parse contracts from environment variables', () => { + process.env.BULK_PAYMENT_TESTNET_CONTRACT_ID = 'CABC123456789012345678901234567890123456789012345678901234'; + process.env.BULK_PAYMENT_TESTNET_VERSION = '1.0.0'; + process.env.BULK_PAYMENT_TESTNET_DEPLOYED_AT = '12345'; + + const service = new ContractConfigService(); + const entries = service.parseEnvVarConfig(); + + expect(entries.length).toBe(1); + expect(entries[0]).toMatchObject({ + contractId: 'CABC123456789012345678901234567890123456789012345678901234', + network: 'testnet', + contractType: 'bulk_payment', + version: '1.0.0', + deployedAt: 12345, + }); + }); + + it('should handle multiple contracts from environment variables', () => { + process.env.BULK_PAYMENT_TESTNET_CONTRACT_ID = 'CABC123456789012345678901234567890123456789012345678901234'; + process.env.VESTING_ESCROW_MAINNET_CONTRACT_ID = 'CDEF123456789012345678901234567890123456789012345678901234'; + + const service = new ContractConfigService(); + const entries = service.parseEnvVarConfig(); + + expect(entries.length).toBe(2); + expect(entries.find(e => e.contractType === 'bulk_payment')).toBeDefined(); + expect(entries.find(e => e.contractType === 'vesting_escrow')).toBeDefined(); + }); + + it('should use default values when version and deployedAt are missing', () => { + process.env.TEST_CONTRACT_TESTNET_CONTRACT_ID = 'CABC123456789012345678901234567890123456789012345678901234'; + + const service = new ContractConfigService(); + const entries = service.parseEnvVarConfig(); + + expect(entries[0].version).toBe('1.0.0'); + expect(entries[0].deployedAt).toBe(0); + }); + }); + + describe('getContractEntries', () => { + it('should prefer TOML over environment variables', () => { + process.env.BULK_PAYMENT_TESTNET_CONTRACT_ID = 'CENV123456789012345678901234567890123456789012345678901234'; + + const service = new ContractConfigService('environments.toml'); + const entries = service.getContractEntries(); + + // Should get entries from TOML, not env vars + expect(entries.length).toBeGreaterThan(0); + + // Verify it's from TOML by checking if we have the expected contract types + const contractTypes = entries.map(e => e.contractType); + expect(contractTypes).toContain('bulk_payment'); + }); + }); +}); diff --git a/backend/src/services/__tests__/csvPayrollImportService.test.ts b/backend/src/services/__tests__/csvPayrollImportService.test.ts index df322a0e..ced93c3a 100644 --- a/backend/src/services/__tests__/csvPayrollImportService.test.ts +++ b/backend/src/services/__tests__/csvPayrollImportService.test.ts @@ -1,6 +1,6 @@ -import { csvPayrollImportService } from '../csvPayrollImportService'; -import { pool } from '../../config/database'; -import { employeeService } from '../employeeService'; +import { csvPayrollImportService } from '../csvPayrollImportService.js'; +import { pool } from '../../config/database.js'; +import { employeeService } from '../employeeService.js'; // Mock database pool jest.mock('../../config/database', () => ({ diff --git a/backend/src/services/__tests__/employeeService.test.ts b/backend/src/services/__tests__/employeeService.test.ts index fef1b81e..676a7be4 100644 --- a/backend/src/services/__tests__/employeeService.test.ts +++ b/backend/src/services/__tests__/employeeService.test.ts @@ -1,4 +1,4 @@ -import { EmployeeService } from '../employeeService'; +import { EmployeeService } from '../employeeService.js'; import { Pool } from 'pg'; // Mock pg Pool @@ -8,7 +8,7 @@ jest.mock('../../config/database', () => ({ }, })); -import { pool } from '../../config/database'; +import { pool } from '../../config/database.js'; describe('EmployeeService', () => { let employeeService: EmployeeService; diff --git a/backend/src/services/__tests__/exportService.test.ts b/backend/src/services/__tests__/exportService.test.ts index d644e79f..b7f5c176 100644 --- a/backend/src/services/__tests__/exportService.test.ts +++ b/backend/src/services/__tests__/exportService.test.ts @@ -1,5 +1,5 @@ -import { ExportService } from '../exportService'; -import { PayrollTransaction } from '../payroll-indexing.service'; +import { ExportService } from '../exportService.js'; +import { PayrollTransaction } from '../payroll-indexing.service.js'; import { PassThrough } from 'stream'; // Mock stream to capture generated data diff --git a/backend/src/services/__tests__/freezeService.test.ts b/backend/src/services/__tests__/freezeService.test.ts index a1e937d0..95bf4935 100644 --- a/backend/src/services/__tests__/freezeService.test.ts +++ b/backend/src/services/__tests__/freezeService.test.ts @@ -34,9 +34,9 @@ jest.mock('../../config/database', () => ({ pool: { query: jest.fn() }, })); -import { FreezeService } from '../freezeService'; -import { StellarService } from '../stellarService'; -import { pool } from '../../config/database'; +import { FreezeService } from '../freezeService.js'; +import { StellarService } from '../stellarService.js'; +import { pool } from '../../config/database.js'; // --------------------------------------------------------------------------- // Helpers diff --git a/backend/src/services/__tests__/ledgerObserverService.test.ts b/backend/src/services/__tests__/ledgerObserverService.test.ts deleted file mode 100644 index d2a83b8c..00000000 --- a/backend/src/services/__tests__/ledgerObserverService.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { LedgerObserverService } from '../ledgerObserverService'; -import { StellarService } from '../stellarService'; -import { pool } from '../../config/database'; -import axios from 'axios'; - -jest.mock('@stellar/stellar-sdk', () => { - return { - ServerApi: {}, - Horizon: { - Server: jest.fn().mockImplementation(() => ({ - payments: jest.fn().mockReturnThis(), - cursor: jest.fn().mockReturnThis(), - stream: jest.fn() - })) - } - }; -}); - -jest.mock('axios'); -jest.mock('../../config/database', () => ({ - pool: { - query: jest.fn(), - }, -})); -jest.mock('../stellarService'); - -describe('LedgerObserverService', () => { - let originalConsoleLog: any; - let originalConsoleError: any; - - beforeAll(() => { - // Suppress console outputs for clean test runs - originalConsoleLog = console.log; - originalConsoleError = console.error; - console.log = jest.fn(); - console.error = jest.fn(); - }); - - afterAll(() => { - console.log = originalConsoleLog; - console.error = originalConsoleError; - }); - - beforeEach(() => { - jest.clearAllMocks(); - // Reset internal state - LedgerObserverService.stop(); - (LedgerObserverService as any).trackedAddresses.clear(); - }); - - describe('Address Tracking', () => { - it('should fetch and cache organization and employee addresses', async () => { - const mockOrgQuery = { rows: [{ id: 1, wallet_address: 'G_ORG_1' }] }; - const mockEmpQuery = { rows: [{ organization_id: 1, wallet_address: 'G_EMP_1' }] }; - - (pool.query as jest.Mock) - .mockResolvedValueOnce(mockOrgQuery) - .mockResolvedValueOnce(mockEmpQuery); - - await (LedgerObserverService as any).refreshTrackedAddresses(); - - const tracked = (LedgerObserverService as any).trackedAddresses; - expect(tracked.size).toBe(2); - expect(tracked.get('G_ORG_1')).toEqual({ address: 'G_ORG_1', organizationId: 1, type: 'organization' }); - expect(tracked.get('G_EMP_1')).toEqual({ address: 'G_EMP_1', organizationId: 1, type: 'employee' }); - }); - }); - - describe('Event Handling', () => { - beforeEach(() => { - // Setup some tracked addresses directly for isolated testing - const tracked = new Map(); - tracked.set('G_KNOWN_ORG', { address: 'G_KNOWN_ORG', organizationId: 99, type: 'organization' }); - (LedgerObserverService as any).trackedAddresses = tracked; - - (pool.query as jest.Mock).mockResolvedValue({ - rows: [{ config_value: JSON.stringify({ webhook_url: 'https://webhook.site/test' }) }] - }); - (axios.post as jest.Mock).mockResolvedValue({ status: 200 }); - }); - - it('should trigger webhook when payment "to" a tracked address is processed', async () => { - const mockPayment = { - id: '1234', - transaction_hash: 'hash1', - type: 'payment', - asset_type: 'native', - amount: '100.0', - from: 'G_UNKNOWN', - to: 'G_KNOWN_ORG' - }; - - await (LedgerObserverService as any).handlePaymentEvent(mockPayment); - - expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining('SELECT config_value FROM tenant_configurations'), - [99] - ); - expect(axios.post).toHaveBeenCalledTimes(1); - expect(axios.post).toHaveBeenCalledWith( - 'https://webhook.site/test', - expect.objectContaining({ - event_type: 'stellar_payment', - address: 'G_KNOWN_ORG', - amount: '100.0' - }), - expect.any(Object) - ); - }); - - it('should not trigger webhook if addresses are not tracked', async () => { - const mockPayment = { - id: '1234', - transaction_hash: 'hash1', - type: 'payment', - from: 'G_UNKNOWN_1', - to: 'G_UNKNOWN_2' - }; - - await (LedgerObserverService as any).handlePaymentEvent(mockPayment); - - expect(axios.post).not.toHaveBeenCalled(); - }); - }); - - describe('Webhook Dispatch Retry', () => { - beforeEach(() => { - jest.useFakeTimers(); - (pool.query as jest.Mock).mockResolvedValue({ - rows: [{ config_value: JSON.stringify({ webhook_url: 'https://webhook.site/fail' }) }] - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should retry failed webhooks up to MAX_RETRIES', async () => { - (axios.post as jest.Mock).mockRejectedValue(new Error('Network Error')); - - // Initial trigger (synchronous error handled, starts timer for retry 1) - const promise = (LedgerObserverService as any).dispatchWebhook(99, { event: 'test' }); - await promise; - expect(axios.post).toHaveBeenCalledTimes(1); - - // Retry 1: Wait 1s - jest.advanceTimersByTime(1000); - await Promise.resolve(); // Allow the promise to execute - await Promise.resolve(); // Allow the catch block to schedule next timer - expect(axios.post).toHaveBeenCalledTimes(2); - - // Retry 2: Wait 2s - jest.advanceTimersByTime(2000); - await Promise.resolve(); - await Promise.resolve(); - expect(axios.post).toHaveBeenCalledTimes(3); - - // Retry 3: Wait 4s - jest.advanceTimersByTime(4000); - await Promise.resolve(); - await Promise.resolve(); - expect(axios.post).toHaveBeenCalledTimes(4); - - // Max retries reached, should not schedule another - jest.advanceTimersByTime(8000); - await Promise.resolve(); - expect(axios.post).toHaveBeenCalledTimes(4); - }); - }); -}); diff --git a/backend/src/services/__tests__/multiSigService.test.ts b/backend/src/services/__tests__/multiSigService.test.ts index f9417924..ebb2afc7 100644 --- a/backend/src/services/__tests__/multiSigService.test.ts +++ b/backend/src/services/__tests__/multiSigService.test.ts @@ -1,5 +1,5 @@ -import { MultiSigService, SignerInfo, MultiSigThresholds } from '../multiSigService'; -import { StellarService } from '../stellarService'; +import { MultiSigService, SignerInfo, MultiSigThresholds } from '../multiSigService.js'; +import { StellarService } from '../stellarService.js'; import { Keypair, Transaction, Networks } from '@stellar/stellar-sdk'; // Mock the StellarService diff --git a/backend/src/services/__tests__/searchService.test.ts b/backend/src/services/__tests__/searchService.test.ts index c848b119..408ca688 100644 --- a/backend/src/services/__tests__/searchService.test.ts +++ b/backend/src/services/__tests__/searchService.test.ts @@ -1,4 +1,4 @@ -import { SearchService } from '../searchService'; +import { SearchService } from '../searchService.js'; import { Pool } from 'pg'; // Mock pg Pool diff --git a/backend/src/services/__tests__/stellarService.test.ts b/backend/src/services/__tests__/stellarService.test.ts index 014d96b4..dd0ca355 100644 --- a/backend/src/services/__tests__/stellarService.test.ts +++ b/backend/src/services/__tests__/stellarService.test.ts @@ -1,4 +1,4 @@ -import { StellarService, TransactionResult, MultiSigConfig } from '../stellarService'; +import { StellarService, TransactionResult, MultiSigConfig } from '../stellarService.js'; import { Keypair, Asset, Operation, Memo, Networks, Transaction } from '@stellar/stellar-sdk'; jest.mock('@stellar/stellar-sdk', () => { diff --git a/backend/src/services/__tests__/taxService.test.ts b/backend/src/services/__tests__/taxService.test.ts index 14661a24..fb2a2d5a 100644 --- a/backend/src/services/__tests__/taxService.test.ts +++ b/backend/src/services/__tests__/taxService.test.ts @@ -1,4 +1,4 @@ -import { TaxService } from '../taxService'; +import { TaxService } from '../taxService.js'; // Mock the pg Pool const mockQuery = jest.fn(); diff --git a/backend/src/services/__tests__/throttlingService.test.ts b/backend/src/services/__tests__/throttlingService.test.ts index 248f4be4..050157f1 100644 --- a/backend/src/services/__tests__/throttlingService.test.ts +++ b/backend/src/services/__tests__/throttlingService.test.ts @@ -1,4 +1,4 @@ -import { ThrottlingService } from '../services/throttlingService'; +import { ThrottlingService } from '../services/throttlingService.js'; describe('ThrottlingService', () => { beforeEach(() => { diff --git a/backend/src/services/anchorService.ts b/backend/src/services/anchorService.ts index 2dd0b159..738a9206 100644 --- a/backend/src/services/anchorService.ts +++ b/backend/src/services/anchorService.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { Keypair, Transaction, Networks, Utils } from '@stellar/stellar-sdk'; -import { StellarService } from './stellarService'; +import { StellarService } from './stellarService.js'; export interface AnchorInfo { domain: string; @@ -70,8 +70,11 @@ export class AnchorService { }); const token = tokenResponse.data.token; - this.anchorCache[domain].token = token; + if (this.anchorCache[domain]) { + this.anchorCache[domain].token = token; + } return token; + } /** diff --git a/backend/src/services/assetService.ts b/backend/src/services/assetService.ts index ebd12515..f006c472 100644 --- a/backend/src/services/assetService.ts +++ b/backend/src/services/assetService.ts @@ -6,8 +6,8 @@ import { AuthClawbackEnabledFlag, AuthRevocableFlag, } from '@stellar/stellar-sdk'; -import { StellarService } from './stellarService'; -import { pool } from '../config/database'; +import { StellarService } from './stellarService.js'; +import { pool } from '../config/database.js'; export class AssetService { /** diff --git a/backend/src/services/balanceService.ts b/backend/src/services/balanceService.ts index 32682d0e..90310dcc 100644 --- a/backend/src/services/balanceService.ts +++ b/backend/src/services/balanceService.ts @@ -1,4 +1,4 @@ -import { StellarService } from './stellarService'; +import { StellarService } from './stellarService.js'; export interface PaymentEntry { employeeId: string; diff --git a/backend/src/services/claimableBalanceService.ts b/backend/src/services/claimableBalanceService.ts deleted file mode 100644 index ca2e098e..00000000 --- a/backend/src/services/claimableBalanceService.ts +++ /dev/null @@ -1,239 +0,0 @@ -import crypto from 'crypto'; -import { Keypair, Asset, Operation, Claimant, StrKey, xdr } from '@stellar/stellar-sdk'; -import pool from '../config/database.js'; -import { StellarService } from './stellarService.js'; - -type EncryptionVersion = 1; - -function getEncryptionKey(): Buffer { - const key = process.env.CUSTODIAL_WALLET_ENCRYPTION_KEY; - if (!key) { - throw new Error('CUSTODIAL_WALLET_ENCRYPTION_KEY is not configured'); - } - - const asHex = /^[0-9a-fA-F]+$/.test(key) && key.length === 64; - const buf = asHex ? Buffer.from(key, 'hex') : Buffer.from(key, 'base64'); - if (buf.length !== 32) { - throw new Error('CUSTODIAL_WALLET_ENCRYPTION_KEY must be 32 bytes (hex-64 or base64)'); - } - return buf; -} - -function encryptSecret(secret: string): { encrypted: string; version: EncryptionVersion } { - const key = getEncryptionKey(); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - - const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - - const payload = Buffer.concat([iv, tag, ciphertext]).toString('base64'); - return { encrypted: payload, version: 1 }; -} - -function decryptSecret(encrypted: string): string { - const key = getEncryptionKey(); - const raw = Buffer.from(encrypted, 'base64'); - - const iv = raw.subarray(0, 12); - const tag = raw.subarray(12, 28); - const ciphertext = raw.subarray(28); - - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - - const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - return plaintext.toString('utf8'); -} - -function extractClaimableBalanceId(resultXdr: string): string | null { - const txResult = xdr.TransactionResult.fromXDR(resultXdr, 'base64'); - const opResults = txResult.result().results(); - if (!opResults || opResults.length === 0) return null; - - for (const opResult of opResults) { - const tr = opResult.tr(); - if (!tr) continue; - - const type = tr.value().switch().name; - if (type !== 'createClaimableBalance') continue; - - const cb = (tr.value() as any).createClaimableBalanceResult?.().balanceId?.(); - if (!cb) continue; - - const raw = cb.v0(); - const balanceId = StrKey.encodeClaimableBalanceId(raw); - return balanceId; - } - - return null; -} - -export class ClaimableBalanceService { - static async ensureCustodialWallet(params: { - organizationId: number; - employeeId: number; - }): Promise<{ walletId: string; publicKey: string; secretKey: string }> { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const existing = await client.query( - `SELECT w.id, w.wallet_address - FROM wallets w - WHERE w.organization_id = $1 AND w.employee_id = $2 AND w.wallet_type = 'employee' AND w.asset_code = 'XLM' AND w.asset_issuer = '' - LIMIT 1`, - [params.organizationId, params.employeeId] - ); - - if (existing.rows.length > 0) { - const row = existing.rows[0]; - const secretResult = await client.query( - 'SELECT encrypted_secret FROM custodial_wallet_secrets WHERE wallet_id = $1', - [row.id] - ); - if (secretResult.rows.length === 0) { - throw new Error('Custodial wallet exists without stored secret'); - } - - const secretKey = decryptSecret(secretResult.rows[0].encrypted_secret); - - await client.query('COMMIT'); - - return { walletId: row.id, publicKey: row.wallet_address, secretKey }; - } - - const kp = Keypair.random(); - const enc = encryptSecret(kp.secret()); - - const walletInsert = await client.query( - `INSERT INTO wallets ( - organization_id, - employee_id, - wallet_address, - wallet_type, - asset_code, - asset_issuer - ) VALUES ($1, $2, $3, 'employee', 'XLM', '') - RETURNING id, wallet_address`, - [params.organizationId, params.employeeId, kp.publicKey()] - ); - - const wallet = walletInsert.rows[0]; - - await client.query( - `INSERT INTO custodial_wallet_secrets (wallet_id, encrypted_secret, encryption_version) - VALUES ($1, $2, $3)`, - [wallet.id, enc.encrypted, enc.version] - ); - - await client.query( - `UPDATE employees - SET wallet_address = COALESCE(wallet_address, $1), status = CASE WHEN status = 'active' THEN 'pending' ELSE status END - WHERE id = $2 AND organization_id = $3`, - [kp.publicKey(), params.employeeId, params.organizationId] - ); - - await client.query('COMMIT'); - - return { walletId: wallet.id, publicKey: wallet.wallet_address, secretKey: kp.secret() }; - } catch (e) { - await client.query('ROLLBACK'); - throw e; - } finally { - client.release(); - } - } - - static async createOrgUsdClaimableBalance(params: { - organizationId: number; - employeeId: number; - amount: string; - assetIssuer: string; - claimantPublicKey: string; - }): Promise<{ claimId: string; balanceId: string; txHash: string }> { - const sourceSecret = process.env.STELLAR_SOURCE_SECRET; - if (!sourceSecret) { - throw new Error('STELLAR_SOURCE_SECRET environment variable not set'); - } - - const sourceKeypair = Keypair.fromSecret(sourceSecret); - - const asset = new Asset('ORGUSD', params.assetIssuer); - const claimant = new Claimant(params.claimantPublicKey, Claimant.predicateUnconditional()); - - const op = Operation.createClaimableBalance({ - asset, - amount: params.amount, - claimants: [claimant], - }); - - const builder = await StellarService.buildTransaction(sourceKeypair.publicKey(), [op], { - timeout: 30, - }); - - const tx = builder.build(); - const signed = StellarService.signTransaction(tx, sourceKeypair); - const result = await StellarService.submitTransaction(signed); - - if (!result.success) { - throw new Error('Failed to submit claimable balance transaction'); - } - - const balanceId = result.resultXdr ? extractClaimableBalanceId(result.resultXdr) : null; - if (!balanceId) { - throw new Error('Unable to extract claimable balance id from result XDR'); - } - - const insert = await pool.query( - `INSERT INTO claimable_balance_claims ( - organization_id, - employee_id, - asset_code, - asset_issuer, - amount, - status, - stellar_balance_id, - create_tx_hash - ) VALUES ($1, $2, 'ORGUSD', $3, $4, 'pending', $5, $6) - RETURNING id`, - [params.organizationId, params.employeeId, params.assetIssuer, params.amount, balanceId, result.hash] - ); - - return { - claimId: insert.rows[0].id, - balanceId, - txHash: result.hash, - }; - } - - static async listPendingClaimsForWallet(params: { - organizationId: number | null; - walletAddress: string; - }): Promise< - Array<{ - id: string; - employee_id: number | null; - amount: string; - asset_code: string; - asset_issuer: string; - stellar_balance_id: string | null; - create_tx_hash: string | null; - created_at: string; - status: string; - }> - > { - const result = await pool.query( - `SELECT c.id, c.employee_id, c.amount, c.asset_code, c.asset_issuer, c.stellar_balance_id, c.create_tx_hash, c.created_at, c.status - FROM claimable_balance_claims c - JOIN employees e ON e.id = c.employee_id - WHERE ($1::int IS NULL OR c.organization_id = $1) - AND e.wallet_address = $2 - AND c.status = 'pending' - ORDER BY c.created_at DESC`, - [params.organizationId, params.walletAddress] - ); - - return result.rows; - } -} diff --git a/backend/src/services/contractConfigService.ts b/backend/src/services/contractConfigService.ts new file mode 100644 index 00000000..8535e295 --- /dev/null +++ b/backend/src/services/contractConfigService.ts @@ -0,0 +1,176 @@ +/** + * Contract Configuration Service + * Parses contract deployment information from environments.toml or environment variables + */ + +import fs from 'fs'; +import path from 'path'; +import toml from 'toml'; +import { ContractEntry } from '../utils/contractValidator.js'; +import logger from '../utils/logger.js'; + +export class ContractConfigService { + private tomlPath: string; + + constructor(tomlPath: string = 'environments.toml') { + // Resolve path relative to project root + this.tomlPath = path.resolve(process.cwd(), tomlPath); + } + + /** + * Parse contracts from environments.toml file + */ + parseTomlConfig(): ContractEntry[] { + try { + if (!fs.existsSync(this.tomlPath)) { + logger.info(`TOML file not found at ${this.tomlPath}, will try environment variables`); + return []; + } + + const fileContent = fs.readFileSync(this.tomlPath, 'utf-8'); + const config = toml.parse(fileContent); + + const entries: ContractEntry[] = []; + + // Parse staging contracts (testnet) + if (config.staging?.contracts) { + const stagingEntries = this.extractContractsFromSection( + config.staging.contracts, + 'testnet' + ); + entries.push(...stagingEntries); + } + + // Parse production contracts (mainnet) + if (config.production?.contracts) { + const productionEntries = this.extractContractsFromSection( + config.production.contracts, + 'mainnet' + ); + entries.push(...productionEntries); + } + + return entries; + } catch (error) { + logger.error(`Error parsing TOML config at ${this.tomlPath}`, error); + return []; + } + } + + /** + * Extract contract entries from a TOML section + */ + private extractContractsFromSection( + contracts: Record, + network: 'testnet' | 'mainnet' + ): ContractEntry[] { + const entries: ContractEntry[] = []; + + for (const [contractType, contractData] of Object.entries(contracts)) { + try { + // Handle both formats: { id: "C..." } and direct string "C..." + let contractId: string; + let version: string = '1.0.0'; // default version + let deployedAt: number = 0; // default ledger sequence + + if (typeof contractData === 'string') { + contractId = contractData; + } else if (typeof contractData === 'object' && contractData !== null) { + contractId = contractData.id || contractData.contractId || ''; + version = contractData.version || version; + deployedAt = contractData.deployed_at || contractData.deployedAt || deployedAt; + } else { + continue; + } + + if (!contractId) { + logger.warn(`Contract ${contractType} in ${network} section has no ID, skipping`); + continue; + } + + entries.push({ + contractId, + network, + contractType, + version, + deployedAt + }); + } catch (error) { + logger.warn(`Error parsing contract ${contractType} in ${network} section`, error); + } + } + + return entries; + } + + /** + * Parse contracts from environment variables + * Pattern: {CONTRACT_TYPE}_{NETWORK}_CONTRACT_ID + */ + parseEnvVarConfig(): ContractEntry[] { + const entries: ContractEntry[] = []; + const processedContracts = new Set(); + + // Iterate through all environment variables + for (const [key, value] of Object.entries(process.env)) { + // Match pattern: {CONTRACT_TYPE}_{NETWORK}_CONTRACT_ID + const contractIdMatch = key.match(/^(.+)_(TESTNET|MAINNET)_CONTRACT_ID$/); + + if (contractIdMatch && value) { + const contractType = contractIdMatch[1]!.toLowerCase(); + const network = contractIdMatch[2]!.toLowerCase() as 'testnet' | 'mainnet'; + const contractKey = `${contractType}_${network}`; + + + // Skip if already processed + if (processedContracts.has(contractKey)) { + continue; + } + + processedContracts.add(contractKey); + + // Get version and deployedAt from corresponding env vars + const versionKey = `${contractIdMatch[1]}_${contractIdMatch[2]}_VERSION`; + const deployedAtKey = `${contractIdMatch[1]}_${contractIdMatch[2]}_DEPLOYED_AT`; + + const version = process.env[versionKey] || '1.0.0'; + const deployedAt = parseInt(process.env[deployedAtKey] || '0', 10); + + entries.push({ + contractId: value, + network, + contractType, + version, + deployedAt + }); + } + } + + return entries; + } + + /** + * Get all contract entries from available sources + * Tries TOML first, falls back to environment variables + */ + getContractEntries(): ContractEntry[] { + // Try TOML first + const tomlEntries = this.parseTomlConfig(); + + if (tomlEntries.length > 0) { + logger.info(`Loaded ${tomlEntries.length} contracts from TOML configuration`); + return tomlEntries; + } + + // Fall back to environment variables + const envEntries = this.parseEnvVarConfig(); + + if (envEntries.length > 0) { + logger.info(`Loaded ${envEntries.length} contracts from environment variables`); + return envEntries; + } + + logger.warn('No contract configuration found in TOML or environment variables'); + return []; + } +} diff --git a/backend/src/services/contractEventIndexerService.ts b/backend/src/services/contractEventIndexerService.ts new file mode 100644 index 00000000..0bf566fc --- /dev/null +++ b/backend/src/services/contractEventIndexerService.ts @@ -0,0 +1,209 @@ +import { query } from '../config/database.js'; + +const INDEX_STATE_KEY = 'soroban_contract_events'; +const DEFAULT_START_LEDGER = Number(process.env.SOROBAN_EVENT_START_LEDGER || '0'); +const POLL_INTERVAL_MS = Number(process.env.SOROBAN_EVENT_POLL_INTERVAL_MS || '12000'); +const DEFAULT_RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; + +interface RpcContractEvent { + id?: string; + type?: string; + txHash?: string; + ledger?: number; + ledgerSequence?: number; + contractId?: string; + topic?: unknown; + value?: unknown; + [key: string]: unknown; +} + +interface GetEventsRpcResponse { + result?: { + events?: RpcContractEvent[]; + latestLedger?: number; + }; + error?: { message?: string }; +} + +export class ContractEventIndexerService { + private static timer: NodeJS.Timeout | null = null; + private static running = false; + private static lock = false; + + static async initialize(): Promise { + await this.ensureSchema(); + await this.ensureStateRow(); + } + + static start(): void { + if (this.running) return; + this.running = true; + + void this.pollOnce(); + this.timer = setInterval(() => { + void this.pollOnce(); + }, POLL_INTERVAL_MS); + } + + static stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.running = false; + } + + static async pollOnce(): Promise { + if (this.lock) return; + this.lock = true; + + try { + const contracts = this.getIndexedContractIds(); + if (contracts.length === 0) return; + + const state = await query( + `SELECT last_ledger_sequence + FROM contract_event_index_state + WHERE state_key = $1`, + [INDEX_STATE_KEY] + ); + + const lastIndexed = Number(state.rows[0]?.last_ledger_sequence ?? DEFAULT_START_LEDGER); + const startLedger = Math.max(lastIndexed + 1, DEFAULT_START_LEDGER); + const events = await this.fetchContractEvents(startLedger, contracts); + + if (events.length === 0) return; + + let maxLedger = lastIndexed; + for (const event of events) { + const contractId = String(event.contractId || ''); + if (!contractId) continue; + + const ledgerSequence = Number(event.ledgerSequence ?? event.ledger ?? 0); + if (!Number.isFinite(ledgerSequence) || ledgerSequence <= 0) continue; + maxLedger = Math.max(maxLedger, ledgerSequence); + + const eventId = String(event.id || `${contractId}-${ledgerSequence}-${event.txHash || ''}`); + const eventType = this.extractEventType(event); + const txHash = event.txHash ? String(event.txHash) : null; + + await query( + `INSERT INTO contract_events (event_id, contract_id, event_type, payload, ledger_sequence, tx_hash) + VALUES ($1, $2, $3, $4::jsonb, $5, $6) + ON CONFLICT (event_id, contract_id) DO NOTHING`, + [eventId, contractId, eventType, JSON.stringify(event), ledgerSequence, txHash] + ); + } + + if (maxLedger > lastIndexed) { + await query( + `UPDATE contract_event_index_state + SET last_ledger_sequence = $1, updated_at = NOW() + WHERE state_key = $2`, + [maxLedger, INDEX_STATE_KEY] + ); + } + } catch (error) { + console.error('Contract event indexer error:', error); + } finally { + this.lock = false; + } + } + + private static async ensureStateRow(): Promise { + await query( + `INSERT INTO contract_event_index_state (state_key, last_ledger_sequence) + VALUES ($1, $2) + ON CONFLICT (state_key) DO NOTHING`, + [INDEX_STATE_KEY, DEFAULT_START_LEDGER] + ); + } + + private static async ensureSchema(): Promise { + await query( + `CREATE TABLE IF NOT EXISTS contract_events ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + ledger_sequence BIGINT NOT NULL, + tx_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )` + ); + + await query( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_events_event_id + ON contract_events (event_id, contract_id)` + ); + + await query( + `CREATE INDEX IF NOT EXISTS idx_contract_events_contract_ledger + ON contract_events (contract_id, ledger_sequence DESC)` + ); + + await query( + `CREATE TABLE IF NOT EXISTS contract_event_index_state ( + id BIGSERIAL PRIMARY KEY, + state_key TEXT NOT NULL UNIQUE, + last_ledger_sequence BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )` + ); + } + + private static getIndexedContractIds(): string[] { + const envIds = [ + process.env.BULK_PAYMENT_CONTRACT_ID, + process.env.VESTING_ESCROW_CONTRACT_ID, + process.env.REVENUE_SPLIT_CONTRACT_ID, + ] + .map((value) => (value || '').trim()) + .filter(Boolean); + + return [...new Set(envIds)]; + } + + private static async fetchContractEvents( + startLedger: number, + contractIds: string[] + ): Promise { + const response = await fetch(DEFAULT_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getEvents', + params: { + startLedger, + filters: [ + { + type: 'contract', + contractIds, + }, + ], + pagination: { limit: 100 }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`getEvents RPC failed with status ${response.status}`); + } + + const payload = (await response.json()) as GetEventsRpcResponse; + if (payload.error?.message) { + throw new Error(payload.error.message); + } + + return payload.result?.events ?? []; + } + + private static extractEventType(event: RpcContractEvent): string { + if (event.type) return String(event.type); + if (Array.isArray(event.topic) && event.topic.length > 0) { + return String(event.topic[0]); + } + return 'unknown'; + } +} diff --git a/backend/src/services/contractUpgradeService.ts b/backend/src/services/contractUpgradeService.ts new file mode 100644 index 00000000..7bfe7c20 --- /dev/null +++ b/backend/src/services/contractUpgradeService.ts @@ -0,0 +1,828 @@ +/** + * Contract Upgrade Service + * + * Manages the full lifecycle of Soroban smart-contract upgrades: + * 1. Registry lookups — read deployed contract state from DB. + * 2. WASM validation — format check + on-chain existence via Soroban RPC. + * 3. Simulation — pre-flight via Soroban RPC simulateTransaction. + * 4. Execution — build, sign, submit the upgrade transaction. + * 5. Migration — run and track post-upgrade data migration steps. + * + * All mutating operations write to contract_upgrade_logs before touching + * the network, providing a consistent audit trail even on partial failure. + * + * Time/space annotations follow the same convention as freezeService.ts. + */ + +import { Keypair, TransactionBuilder, Networks, SorobanRpc, Contract, xdr } from '@stellar/stellar-sdk'; +import { pool } from '../config/database.js'; + +// --------------------------------------------------------------------------- +// Environment helpers (mirror the pattern from stellarService.ts) +// --------------------------------------------------------------------------- + +function getSorobanRpcUrl(): string { + return (process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org').replace(/\/+$/, ''); +} + +function getNetworkPassphrase(): string { + return process.env.STELLAR_NETWORK === 'MAINNET' + ? Networks.PUBLIC + : Networks.TESTNET; +} + +function getRpcServer(): SorobanRpc.Server { + return new SorobanRpc.Server(getSorobanRpcUrl(), { allowHttp: false }); +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface ContractRecord { + id: number; + name: string; + description: string | null; + network: string; + contract_id: string; + current_wasm_hash: string; + version: string; + last_upgraded_at: string | null; + last_upgraded_by: string | null; + created_at: string; +} + +export interface MigrationStep { + id: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + message: string | null; +} + +export interface UpgradeLog { + id: number; + registry_id: number; + previous_wasm_hash: string; + new_wasm_hash: string; + status: 'pending' | 'simulated' | 'confirmed' | 'executing' | 'completed' | 'failed' | 'cancelled'; + simulation_result: UpgradeSimulationResult | null; + tx_hash: string | null; + migration_steps: MigrationStep[]; + initiated_by: string; + notes: string | null; + error_message: string | null; + created_at: string; + completed_at: string | null; +} + +export interface UpgradeSimulationResult { + success: boolean; + estimatedFee: string; + estimatedFeeXlm: string; + cpuInstructions: string; + memoryBytes: string; + latestLedger: number; + transactionData: string | null; + warnings: string[]; + error: string | null; +} + +export interface ExecuteUpgradeResult { + upgradeLogId: number; + txHash: string; + status: 'executing'; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** WASM hash must be exactly 64 lowercase hex chars (SHA-256 of WASM bytes). */ +const WASM_HASH_REGEX = /^[0-9a-f]{64}$/i; + +/** Default post-upgrade migration steps — extend per contract in future. */ +const DEFAULT_MIGRATION_STEPS: Omit[] = [ + { name: 'Verify on-chain contract state', status: 'pending', message: null }, + { name: 'Validate storage schema compatibility', status: 'pending', message: null }, + { name: 'Re-index contract data entries', status: 'pending', message: null }, + { name: 'Emit upgrade audit event', status: 'pending', message: null }, +]; + +/** + * Builds a deterministic set of migration steps with stable IDs. + * IDs are positional (step-0 … step-N) so the frontend can key React + * list items without needing UUIDs. + * + * Time/space: O(k) where k = number of steps (constant ~4). + */ +function buildDefaultMigrationSteps(): MigrationStep[] { + return DEFAULT_MIGRATION_STEPS.map((s, i) => ({ id: `step-${i}`, ...s })); +} + +/** + * Converts stroops (integer) to XLM string with 7 decimal places. + * Same helper used in feeEstimation.ts on the frontend. + */ +function stroopsToXlm(stroops: string | number): string { + return (Number(stroops) / 10_000_000).toFixed(7); +} + +// --------------------------------------------------------------------------- +// ContractUpgradeService +// --------------------------------------------------------------------------- + +export class ContractUpgradeService { + // ------------------------------------------------------------------------- + // Registry queries + // ------------------------------------------------------------------------- + + /** + * Return all contracts in the registry ordered by name. + * + * Time complexity: O(n) where n = registry size (bounded, typically < 20). + * Space complexity: O(n). + */ + static async listContracts(): Promise { + const result = await pool.query( + `SELECT * FROM contract_registry ORDER BY name ASC` + ); + return result.rows; + } + + /** + * Return a single contract by its registry ID. + * + * Time complexity: O(1) — primary key lookup. + * Space complexity: O(1). + */ + static async getContract(registryId: number): Promise { + const result = await pool.query( + `SELECT * FROM contract_registry WHERE id = $1`, + [registryId] + ); + return result.rows[0] ?? null; + } + + // ------------------------------------------------------------------------- + // WASM hash validation + // ------------------------------------------------------------------------- + + /** + * Validate the proposed new WASM hash: + * 1. Format: must be exactly 64 lowercase hex chars. + * 2. Difference: must differ from the contract's current hash. + * 3. On-chain existence: query Soroban RPC getLedgerEntries to confirm + * the WASM has been uploaded to the network. + * + * Returns { valid: true } or { valid: false, reason: string }. + * + * Time complexity: O(1) — one RPC round-trip. + * Space complexity: O(1). + */ + static async validateWasmHash( + registryId: number, + newWasmHash: string + ): Promise<{ valid: boolean; reason?: string }> { + // ── 1. Format check ────────────────────────────────────────────────── + if (!WASM_HASH_REGEX.test(newWasmHash)) { + return { valid: false, reason: 'WASM hash must be exactly 64 lowercase hex characters (SHA-256).' }; + } + + // ── 2. Retrieve current hash from registry ─────────────────────────── + const contract = await ContractUpgradeService.getContract(registryId); + if (!contract) { + return { valid: false, reason: 'Contract not found in registry.' }; + } + + if (newWasmHash.toLowerCase() === contract.current_wasm_hash.toLowerCase()) { + return { valid: false, reason: 'New WASM hash is identical to the currently deployed hash. No upgrade needed.' }; + } + + // ── 3. On-chain existence check via Soroban RPC ────────────────────── + try { + const server = getRpcServer(); + const hashBytes = Buffer.from(newWasmHash, 'hex'); + + const wasmKey = xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ hash: hashBytes }) + ); + + const response = await server.getLedgerEntries(wasmKey); + + if (!response.entries || response.entries.length === 0) { + return { + valid: false, + reason: 'WASM hash not found on the network. Upload the WASM bytecode first via `stellar contract upload`.', + }; + } + } catch (rpcError: unknown) { + // RPC unreachable — skip on-chain check rather than blocking the flow. + // Log the issue but allow the admin to proceed with format-valid hashes. + console.warn('ContractUpgradeService: RPC reachability check failed, skipping on-chain validation:', rpcError); + } + + return { valid: true }; + } + + // ------------------------------------------------------------------------- + // Simulation + // ------------------------------------------------------------------------- + + /** + * Simulate the upgrade transaction via Soroban RPC without broadcasting. + * + * Builds a transaction that calls contract.upgrade(new_wasm_hash), submits + * it to simulateTransaction, and parses the cost/error response. + * + * A new upgrade log row is created in 'pending' state before simulation, + * then advanced to 'simulated' or 'failed' based on the result. + * + * Time complexity: O(1) — one RPC round-trip + two DB writes. + * Space complexity: O(1). + * + * @param registryId - DB id from contract_registry. + * @param newWasmHash - The 64-char hex WASM hash to upgrade to. + * @param initiatedBy - Wallet address of the admin triggering the upgrade. + * @param notes - Optional changelog notes for this upgrade. + */ + static async simulateUpgrade( + registryId: number, + newWasmHash: string, + initiatedBy: string, + notes?: string + ): Promise<{ upgradeLogId: number; simulation: UpgradeSimulationResult }> { + const contract = await ContractUpgradeService.getContract(registryId); + if (!contract) throw new Error('Contract not found in registry.'); + + const migrationSteps = buildDefaultMigrationSteps(); + + // ── Create log row in 'pending' state ─────────────────────────────── + const logResult = await pool.query<{ id: number }>( + `INSERT INTO contract_upgrade_logs + (registry_id, previous_wasm_hash, new_wasm_hash, status, + migration_steps, initiated_by, notes) + VALUES ($1, $2, $3, 'pending', $4, $5, $6) + RETURNING id`, + [ + registryId, + contract.current_wasm_hash, + newWasmHash.toLowerCase(), + JSON.stringify(migrationSteps), + initiatedBy, + notes ?? null, + ] + ); + const upgradeLogId = logResult.rows[0]?.id; + if (!upgradeLogId) throw new Error('Failed to insert log row'); + + // ── Attempt Soroban RPC simulation ─────────────────────────────────── + let simulation: UpgradeSimulationResult; + + try { + const server = getRpcServer(); + const networkPassphrase = getNetworkPassphrase(); + const rpcUrl = getSorobanRpcUrl(); + + // We need a funded source account to build a valid transaction. + // Use the contract's admin address from registry as a best-effort + // source; if unavailable, fall back to a static fee-only account. + let sourceAccount; + try { + sourceAccount = await server.getAccount(initiatedBy); + } catch { + // If the admin account is not on-chain yet (testnet), use RPC + // directly via raw JSON-RPC to avoid crashing the simulation flow. + sourceAccount = null; + } + + if (sourceAccount) { + const sorobanContract = new Contract(contract.contract_id); + const hashBytes = Buffer.from(newWasmHash, 'hex'); + + const upgradeOp = sorobanContract.call( + 'upgrade', + xdr.ScVal.scvBytes(hashBytes) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: '1000000', // generous upper bound for simulation + networkPassphrase, + }) + .addOperation(upgradeOp) + .setTimeout(30) + .build(); + + const simResponse = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResponse)) { + simulation = { + success: false, + estimatedFee: '0', + estimatedFeeXlm: '0.0000000', + cpuInstructions: '0', + memoryBytes: '0', + latestLedger: simResponse.latestLedger, + transactionData: null, + warnings: [], + error: simResponse.error, + }; + } else { + // isSimulationSuccess or isSimulationRestore — both carry minResourceFee/cost + const minFee = simResponse.minResourceFee ?? '0'; + // SorobanDataBuilder.build() yields xdr.SorobanTransactionData; + // serialize as base64 for storage/debug purposes. + const txDataXdr: string | null = (() => { + try { + return simResponse.transactionData.build().toXDR('base64'); + } catch { + return null; + } + })(); + const restoreWarning = SorobanRpc.Api.isSimulationRestore(simResponse) + ? ['Ledger entry restoration required before upgrade.'] + : []; + + simulation = { + success: true, + estimatedFee: minFee, + estimatedFeeXlm: stroopsToXlm(minFee), + cpuInstructions: simResponse.cost?.cpuInsns ?? '0', + memoryBytes: simResponse.cost?.memBytes ?? '0', + latestLedger: simResponse.latestLedger, + transactionData: txDataXdr, + warnings: restoreWarning, + error: null, + }; + } + } else { + // Source account unavailable — perform raw RPC call for cost estimate + simulation = await ContractUpgradeService.rawRpcSimulate( + rpcUrl, + contract.contract_id, + newWasmHash + ); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Simulation failed'; + simulation = { + success: false, + estimatedFee: '0', + estimatedFeeXlm: '0.0000000', + cpuInstructions: '0', + memoryBytes: '0', + latestLedger: 0, + transactionData: null, + warnings: [], + error: message, + }; + } + + // ── Persist simulation result, advance status ──────────────────────── + const newStatus = simulation.success ? 'simulated' : 'failed'; + await pool.query( + `UPDATE contract_upgrade_logs + SET status = $1, simulation_result = $2, error_message = $3, + completed_at = CASE WHEN $1 = 'failed' THEN NOW() ELSE NULL END + WHERE id = $4`, + [ + newStatus, + JSON.stringify(simulation), + simulation.error ?? null, + upgradeLogId, + ] + ); + + return { upgradeLogId, simulation }; + } + + /** + * Fallback simulation: raw JSON-RPC call when the admin account is not + * yet loadable from the RPC server (e.g. new testnet keypair). + * Returns a partial result with cost estimate from the RPC fee stats. + * + * Time complexity: O(1). + * Space complexity: O(1). + */ + private static async rawRpcSimulate( + rpcUrl: string, + _contractId: string, + _newWasmHash: string + ): Promise { + // Without a funded source account we cannot build a valid signed + // transaction for simulation. Return a synthetic cost estimate based + // on typical Soroban upgrade resource usage so the UI can still show + // a fee preview. + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getFeeStats', + params: {}, + }), + }); + + const json = (await response.json()) as { + result?: { sorobanInclusionFee?: { p50?: string } }; + }; + + const p50 = json.result?.sorobanInclusionFee?.p50 ?? '1000000'; + return { + success: true, + estimatedFee: p50, + estimatedFeeXlm: stroopsToXlm(p50), + cpuInstructions: 'N/A', + memoryBytes: 'N/A', + latestLedger: 0, + transactionData: null, + warnings: ['Source account not on-chain — fee is a network median estimate.'], + error: null, + }; + } catch { + return { + success: true, + estimatedFee: '1000000', + estimatedFeeXlm: stroopsToXlm(1000000), + cpuInstructions: 'N/A', + memoryBytes: 'N/A', + latestLedger: 0, + transactionData: null, + warnings: ['Could not reach RPC for fee estimate. Default shown.'], + error: null, + }; + } + } + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + /** + * Execute an already-simulated upgrade on-chain. + * + * Transitions the log from 'confirmed' → 'executing', submits the + * transaction, then transitions to 'completed' (or 'failed'). + * The contract_registry row is updated atomically with the new hash + * and version on success. + * + * Migration steps are run synchronously after chain confirmation to keep + * the implementation simple (no background worker dependency). For large + * migrations, this should be extracted to a background job queue. + * + * Time complexity: O(k) where k = number of migration steps. + * Space complexity: O(1). + * + * @param upgradeLogId - The ID of the existing upgrade log in 'simulated' state. + * @param adminSecret - Stellar secret key of the contract admin. + */ + static async executeUpgrade( + upgradeLogId: number, + adminSecret: string + ): Promise { + // ── Load the upgrade log ───────────────────────────────────────────── + const logResult = await pool.query( + `SELECT ul.*, cr.contract_id, cr.version as current_version + FROM contract_upgrade_logs ul + JOIN contract_registry cr ON cr.id = ul.registry_id + WHERE ul.id = $1`, + [upgradeLogId] + ); + const log = logResult.rows[0]; + if (!log) throw new Error('Upgrade log not found.'); + + if (!['simulated', 'confirmed'].includes(log.status)) { + throw new Error(`Cannot execute upgrade in status '${log.status}'. Expected 'simulated' or 'confirmed'.`); + } + + // ── Advance status to 'confirmed' then 'executing' ─────────────────── + await pool.query( + `UPDATE contract_upgrade_logs SET status = 'executing' WHERE id = $1`, + [upgradeLogId] + ); + + let txHash: string; + + try { + const adminKeypair = Keypair.fromSecret(adminSecret); + const server = getRpcServer(); + const networkPassphrase = getNetworkPassphrase(); + + const sourceAccount = await server.getAccount(adminKeypair.publicKey()); + + const contractRecord = await pool.query<{ contract_id: string }>( + `SELECT cr.contract_id FROM contract_registry cr + JOIN contract_upgrade_logs ul ON ul.registry_id = cr.id + WHERE ul.id = $1`, + [upgradeLogId] + ); + const contractId = contractRecord.rows[0]?.contract_id; + if (!contractId) throw new Error('Contract ID not found for upgrade log.'); + + const sorobanContract = new Contract(contractId); + const hashBytes = Buffer.from(log.new_wasm_hash, 'hex'); + + const upgradeOp = sorobanContract.call( + 'upgrade', + xdr.ScVal.scvBytes(hashBytes) + ); + + const rawTx = new TransactionBuilder(sourceAccount, { + fee: '1000000', + networkPassphrase, + }) + .addOperation(upgradeOp) + .setTimeout(30) + .build(); + + // Simulate to get resource footprint, then assemble + const simResult = await server.simulateTransaction(rawTx); + if (!SorobanRpc.Api.isSimulationSuccess(simResult)) { + const errMsg = SorobanRpc.Api.isSimulationError(simResult) + ? simResult.error + : 'Simulation failed before submission'; + throw new Error(errMsg); + } + + const preparedTx = SorobanRpc.assembleTransaction(rawTx, simResult).build(); + preparedTx.sign(adminKeypair); + + const sendResponse = await server.sendTransaction(preparedTx); + if (sendResponse.status === 'ERROR') { + throw new Error(sendResponse.errorResult?.toString() ?? 'Transaction submission failed'); + } + + // Poll for confirmation (max 10 ledgers ≈ ~50 s) + const confirmedTx = await ContractUpgradeService.pollForConfirmation( + server, + sendResponse.hash + ); + + txHash = confirmedTx.hash; + + // ── Update contract registry with new hash ───────────────────────── + await pool.query( + `UPDATE contract_registry + SET current_wasm_hash = $1, + last_upgraded_at = NOW(), + last_upgraded_by = $2 + WHERE id = ( + SELECT registry_id FROM contract_upgrade_logs WHERE id = $3 + )`, + [log.new_wasm_hash, adminKeypair.publicKey(), upgradeLogId] + ); + } catch (execError: unknown) { + const errMsg = execError instanceof Error ? execError.message : 'Execution failed'; + await pool.query( + `UPDATE contract_upgrade_logs + SET status = 'failed', error_message = $1, completed_at = NOW() + WHERE id = $2`, + [errMsg, upgradeLogId] + ); + throw execError; + } + + // ── Persist tx hash, start migration ──────────────────────────────── + await pool.query( + `UPDATE contract_upgrade_logs SET tx_hash = $1 WHERE id = $2`, + [txHash, upgradeLogId] + ); + + // Run migration steps asynchronously (fire-and-forget from the + // caller's perspective; the client polls /status). + void ContractUpgradeService.runMigrationSteps(upgradeLogId); + + return { upgradeLogId, txHash, status: 'executing' }; + } + + /** + * Poll Soroban RPC until the transaction reaches a terminal status. + * Backs off exponentially up to 10 attempts (~50 s total). + * + * Time complexity: O(p) where p = polling attempts (≤ 10). + * Space complexity: O(1). + */ + private static async pollForConfirmation( + server: SorobanRpc.Server, + hash: string + ): Promise<{ hash: string }> { + const MAX_POLLS = 10; + const POLL_INTERVAL_MS = 5_000; + + for (let attempt = 0; attempt < MAX_POLLS; attempt++) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + + const txStatus = await server.getTransaction(hash); + + if (txStatus.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return { hash }; + } + if (txStatus.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error(`Transaction ${hash} failed on-chain.`); + } + // NOT_FOUND or still PENDING — continue polling + } + + throw new Error(`Transaction ${hash} did not confirm within ${MAX_POLLS * POLL_INTERVAL_MS / 1000}s.`); + } + + // ------------------------------------------------------------------------- + // Migration steps + // ------------------------------------------------------------------------- + + /** + * Execute post-upgrade migration steps sequentially and persist + * progress after each step. + * + * Steps are intentionally kept lightweight (validation, re-indexing) + * so they can run synchronously in this Node.js process. CPU-heavy + * migrations should be offloaded to a background job queue. + * + * Time complexity: O(k) where k = number of steps. + * Space complexity: O(k). + */ + private static async runMigrationSteps(upgradeLogId: number): Promise { + const logResult = await pool.query<{ migration_steps: MigrationStep[]; registry_id: number }>( + `SELECT migration_steps, registry_id FROM contract_upgrade_logs WHERE id = $1`, + [upgradeLogId] + ); + const row = logResult.rows[0]; + if (!row) return; + + const steps: MigrationStep[] = row.migration_steps; + + for (let i = 0; i < steps.length; i++) { + const currentStep = steps[i]; + if (!currentStep) continue; + + steps[i] = { ...currentStep, status: 'running', message: null }; + await ContractUpgradeService.persistMigrationSteps(upgradeLogId, steps); + + try { + await ContractUpgradeService.executeStep(steps[i]!, row.registry_id); + steps[i] = { ...steps[i]!, status: 'completed', message: 'Step completed successfully.' }; + } catch (stepErr: unknown) { + const msg = stepErr instanceof Error ? stepErr.message : 'Step failed'; + steps[i] = { ...steps[i]!, status: 'failed', message: msg }; + await ContractUpgradeService.persistMigrationSteps(upgradeLogId, steps); + + // Mark the upgrade log as failed on step failure + await pool.query( + `UPDATE contract_upgrade_logs + SET status = 'failed', error_message = $1, completed_at = NOW() + WHERE id = $2`, + [`Migration step '${steps[i]!.name}' failed: ${msg}`, upgradeLogId] + ); + return; + } + + + await ContractUpgradeService.persistMigrationSteps(upgradeLogId, steps); + } + + // All steps completed — mark the upgrade as completed + await pool.query( + `UPDATE contract_upgrade_logs + SET status = 'completed', completed_at = NOW() + WHERE id = $1`, + [upgradeLogId] + ); + } + + /** + * Execute a single named migration step. + * Each case performs a lightweight validation or re-indexing operation. + * + * Time complexity: O(1) per step (DB queries with indexed lookups). + */ + private static async executeStep(step: MigrationStep, registryId: number): Promise { + // Artificial latency simulates async work for the demo; replace with + // real DB migrations, re-indexing tasks, or webhook notifications. + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + switch (step.id) { + case 'step-0': { + // Verify contract still queryable from RPC after upgrade + await delay(1_500); + const contract = await ContractUpgradeService.getContract(registryId); + if (!contract) throw new Error('Contract missing from registry after upgrade.'); + break; + } + case 'step-1': { + // Validate storage schema: check that the registry row reflects + // the new WASM hash (written during executeUpgrade) + await delay(2_000); + const result = await pool.query<{ current_wasm_hash: string }>( + `SELECT current_wasm_hash FROM contract_registry WHERE id = $1`, + [registryId] + ); + if (!result.rows[0]) throw new Error('Registry row not found during schema validation.'); + break; + } + case 'step-2': { + // Re-index: touch the registry timestamp to signal completion + await delay(2_500); + break; + } + case 'step-3': { + // Audit event: log to audit_logs table + await delay(500); + await pool.query( + `INSERT INTO audit_log_actions (action) VALUES ('contract_upgraded') ON CONFLICT DO NOTHING` + ); + break; + } + default: + await delay(1_000); + } + } + + /** + * Overwrite migration_steps JSON in the DB. + * Single JSONB column update — O(1) DB cost. + */ + private static async persistMigrationSteps( + upgradeLogId: number, + steps: MigrationStep[] + ): Promise { + await pool.query( + `UPDATE contract_upgrade_logs SET migration_steps = $1 WHERE id = $2`, + [JSON.stringify(steps), upgradeLogId] + ); + } + + // ------------------------------------------------------------------------- + // Status & history queries + // ------------------------------------------------------------------------- + + /** + * Fetch the current status of an upgrade log (for polling). + * + * Time complexity: O(1) — primary key lookup. + * Space complexity: O(1). + */ + static async getUpgradeLogStatus(upgradeLogId: number): Promise { + const result = await pool.query( + `SELECT * FROM contract_upgrade_logs WHERE id = $1`, + [upgradeLogId] + ); + return result.rows[0] ?? null; + } + + /** + * Paginated list of upgrade logs for a given contract. + * + * Time complexity: O(k) where k = limit. + * Space complexity: O(k). + */ + static async listUpgradeLogs( + registryId: number, + page = 1, + limit = 20 + ): Promise<{ data: UpgradeLog[]; total: number; page: number; limit: number }> { + const safeLimit = Math.min(100, Math.max(1, limit)); + const offset = (Math.max(1, page) - 1) * safeLimit; + + const [countResult, dataResult] = await Promise.all([ + pool.query<{ count: string }>( + `SELECT COUNT(*) FROM contract_upgrade_logs WHERE registry_id = $1`, + [registryId] + ), + pool.query( + `SELECT * FROM contract_upgrade_logs + WHERE registry_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [registryId, safeLimit, offset] + ), + ]); + + return { + data: dataResult.rows, + total: parseInt(countResult.rows[0]?.count || '0', 10), + page: Math.max(1, page), + limit: safeLimit, + }; + + } + + /** + * Cancel a pending or simulated upgrade that has not yet been executed. + * + * Time complexity: O(1). + * Space complexity: O(1). + */ + static async cancelUpgrade(upgradeLogId: number): Promise { + const result = await pool.query( + `UPDATE contract_upgrade_logs + SET status = 'cancelled', completed_at = NOW() + WHERE id = $1 AND status IN ('pending', 'simulated', 'confirmed') + RETURNING id`, + [upgradeLogId] + ); + if (result.rowCount === 0) { + throw new Error('Upgrade cannot be cancelled — it may have already started executing.'); + } + } +} diff --git a/backend/src/services/csvPayrollImportService.ts b/backend/src/services/csvPayrollImportService.ts index cc744f61..0c97bf9e 100644 --- a/backend/src/services/csvPayrollImportService.ts +++ b/backend/src/services/csvPayrollImportService.ts @@ -1,10 +1,10 @@ import * as csv from 'fast-csv'; import { Readable } from 'stream'; import { StrKey } from '@stellar/stellar-sdk'; -import { createEmployeeSchema, CreateEmployeeInput } from '../schemas/employeeSchema'; -import { employeeService } from './employeeService'; -import { pool } from '../config/database'; -import logger from '../utils/logger'; +import { createEmployeeSchema, CreateEmployeeInput } from '../schemas/employeeSchema.js'; +import { employeeService } from './employeeService.js'; +import { pool } from '../config/database.js'; +import logger from '../utils/logger.js'; export interface CsvRow { first_name: string; diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index f7bf8fb6..ba2a7489 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -1,9 +1,9 @@ -import { pool } from '../config/database'; +import { pool } from '../config/database.js'; import { CreateEmployeeInput, UpdateEmployeeInput, EmployeeQueryInput, -} from '../schemas/employeeSchema'; +} from '../schemas/employeeSchema.js'; export class EmployeeService { async create(data: CreateEmployeeInput, dbClient?: any) { diff --git a/backend/src/services/exportService.ts b/backend/src/services/exportService.ts index b3b0f553..d2c28d83 100644 --- a/backend/src/services/exportService.ts +++ b/backend/src/services/exportService.ts @@ -1,7 +1,7 @@ import PDFDocument from 'pdfkit'; import ExcelJS from 'exceljs'; import * as csv from 'fast-csv'; -import { PayrollTransaction } from './payroll-indexing.service'; +import { PayrollTransaction } from './payroll-indexing.service.js'; export class ExportService { /** diff --git a/backend/src/services/freezeService.ts b/backend/src/services/freezeService.ts index 96035df1..72dfd997 100644 --- a/backend/src/services/freezeService.ts +++ b/backend/src/services/freezeService.ts @@ -1,6 +1,6 @@ import { Asset, Keypair, Operation, TransactionBuilder } from '@stellar/stellar-sdk'; -import { StellarService } from './stellarService'; -import { pool } from '../config/database'; +import { StellarService } from './stellarService.js'; +import { pool } from '../config/database.js'; // --------------------------------------------------------------------------- // Public types diff --git a/backend/src/services/ledgerObserverService.ts b/backend/src/services/ledgerObserverService.ts deleted file mode 100644 index 0d9697ca..00000000 --- a/backend/src/services/ledgerObserverService.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { StellarService } from './stellarService'; -import { pool } from '../config/database'; -import axios from 'axios'; -import { ServerApi } from '@stellar/stellar-sdk/lib/horizon'; - -interface TrackedAddress { - address: string; - organizationId: number; - type: 'organization' | 'employee'; -} - -export class LedgerObserverService { - private static isRunning = false; - private static closeStream: (() => void) | null = null; - private static trackedAddresses: Map = new Map(); - private static refreshInterval: NodeJS.Timeout | null = null; - private static readonly MAX_WEBHOOK_RETRIES = 3; - - static async start() { - if (this.isRunning) { - console.log('LedgerObserverService is already running.'); - return; - } - - console.log('Starting LedgerObserverService...'); - this.isRunning = true; - - // Fetch initial addresses - await this.refreshTrackedAddresses(); - - // Set up periodic refresh (every 5 minutes) - this.refreshInterval = setInterval(async () => { - await this.refreshTrackedAddresses(); - }, 5 * 60 * 1000); - - // Start listening to the network - this.startStream(); - } - - static stop() { - console.log('Stopping LedgerObserverService...'); - if (this.closeStream) { - this.closeStream(); - this.closeStream = null; - } - if (this.refreshInterval) { - clearInterval(this.refreshInterval); - this.refreshInterval = null; - } - this.isRunning = false; - } - - private static async refreshTrackedAddresses() { - try { - const newTrackedAddresses = new Map(); - - // 1. Fetch Organization Addresses (Tenant Configs or Organizations table if multi-tenant) - // Assuming an organizations table exists with stellar_address/wallet_address - const orgQuery = `SELECT id, wallet_address FROM organizations WHERE wallet_address IS NOT NULL`; - const orgResult = await pool.query(orgQuery); - orgResult.rows.forEach(row => { - newTrackedAddresses.set(row.wallet_address, { - address: row.wallet_address, - organizationId: row.id, - type: 'organization' - }); - }); - - // 2. Fetch Employee Addresses - const empQuery = `SELECT organization_id, wallet_address FROM employees WHERE wallet_address IS NOT NULL AND status = 'active'`; - const empResult = await pool.query(empQuery); - empResult.rows.forEach(row => { - newTrackedAddresses.set(row.wallet_address, { - address: row.wallet_address, - organizationId: row.organization_id, - type: 'employee' - }); - }); - - this.trackedAddresses = newTrackedAddresses; - console.log(`[LedgerObserver] Tracked ${this.trackedAddresses.size} addresses.`); - } catch (error) { - console.error('[LedgerObserver] Failed to refresh tracked addresses:', error); - } - } - - private static startStream() { - const server = StellarService.getServer(); - - try { - this.closeStream = server.payments() - .cursor('now') - .stream({ - onmessage: (record) => { - // Type assertion since stream returns a generic record that we know is an operation - const payment = record as unknown as ServerApi.PaymentOperationRecord; - this.handlePaymentEvent(payment); - }, - onerror: (error) => { - console.error('[LedgerObserver] Stream error:', error); - // Implement backoff or simple restart in production - } - }); - } catch (error) { - console.error('[LedgerObserver] Error starting stream:', error); - this.isRunning = false; - } - } - - private static async handlePaymentEvent(payment: ServerApi.PaymentOperationRecord) { - try { - // Check if 'to' or 'from' is in our tracked addresses - const involvedAddresses = new Set(); - - if (payment.source_account) involvedAddresses.add(payment.source_account); - if ('to' in payment) involvedAddresses.add((payment as any).to); - if ('from' in payment) involvedAddresses.add((payment as any).from); - if ('funder' in payment) involvedAddresses.add((payment as any).funder); - if ('account' in payment) involvedAddresses.add((payment as any).account); - if ('trustor' in payment) involvedAddresses.add((payment as any).trustor); - - for (const addr of involvedAddresses) { - const tracked = this.trackedAddresses.get(addr); - if (tracked) { - console.log(`[LedgerObserver] Relevant event detected for Org ${tracked.organizationId}: ${payment.type} (Tx: ${payment.transaction_hash})`); - - // Construct payload - const payload = { - event_type: 'stellar_payment', - timestamp: new Date().toISOString(), - organization_id: tracked.organizationId, - address_type: tracked.type, - address: addr, - operation_id: payment.id, - transaction_hash: payment.transaction_hash, - type: payment.type, - asset: 'asset_type' in payment ? (payment as any).asset_type : 'native', - amount: 'amount' in payment ? (payment as any).amount : null, - from: 'from' in payment ? (payment as any).from : (payment as any).source_account, - to: 'to' in payment ? (payment as any).to : null - }; - - await this.dispatchWebhook(tracked.organizationId, payload); - - // If one event matches multiple rules, we might want to just notify once per org. - break; - } - } - } catch (error) { - console.error('[LedgerObserver] Error processing event:', error); - } - } - - private static async dispatchWebhook(organizationId: number, payload: unknown, retryCount = 0) { - try { - // Fetch webhook URL directly from DB if tenant configurations exist or fallback to process.env - let webhookUrl = process.env.DEFAULT_WEBHOOK_URL; - - try { - const query = `SELECT config_value FROM tenant_configurations WHERE organization_id = $1 AND config_key = 'notification_settings'`; - const result = await pool.query(query, [organizationId]); - if (result.rows.length > 0 && result.rows[0].config_value) { - const settings = typeof result.rows[0].config_value === 'string' - ? JSON.parse(result.rows[0].config_value) - : result.rows[0].config_value; - if (settings.webhook_url) { - webhookUrl = settings.webhook_url; - } - } - } catch (dbErr) { - // Table might not exist yet, fallback is already set - console.log(`[LedgerObserver] Could not fetch tenant webhook configs, using default if available.`); - } - - if (!webhookUrl) { - // No webhook configured for this organization - return; - } - - console.log(`[LedgerObserver] Dispatching webhook to Org ${organizationId} at ${webhookUrl}`); - - await axios.post(webhookUrl, payload, { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'PayD-Ledger-Observer/1.0', - 'X-PayD-Event': (payload as any).event_type - }, - timeout: 5000 // 5 second timeout - }); - - console.log(`[LedgerObserver] Webhook delivered successfully to Org ${organizationId}`); - - } catch (error) { - const errMessage = error instanceof Error ? error.message : String(error); - console.error(`[LedgerObserver] Webhook delivery failed for Org ${organizationId}: ${errMessage}`); - - if (retryCount < this.MAX_WEBHOOK_RETRIES) { - const delay = Math.pow(2, retryCount) * 1000; // Exponential backoff: 1s, 2s, 4s... - console.log(`[LedgerObserver] Retrying webhook in ${delay}ms (Attempt ${retryCount + 1}/${this.MAX_WEBHOOK_RETRIES})`); - - setTimeout(() => { - this.dispatchWebhook(organizationId, payload, retryCount + 1); - }, delay); - } else { - console.error(`[LedgerObserver] Webhook max retries reached for Org ${organizationId}`); - // In a true enterprise system, we might log this to a dead-letter queue or DB table here - } - } - } -} - -export default LedgerObserverService; diff --git a/backend/src/services/multiSigService.ts b/backend/src/services/multiSigService.ts index 51517453..3bb25595 100644 --- a/backend/src/services/multiSigService.ts +++ b/backend/src/services/multiSigService.ts @@ -1,5 +1,5 @@ import { Keypair } from '@stellar/stellar-sdk'; -import { StellarService, MultiSigConfig } from './stellarService'; +import { StellarService, MultiSigConfig } from './stellarService.js'; export interface MultiSigThresholds { low: number; diff --git a/backend/src/services/payroll-indexing.service.ts b/backend/src/services/payroll-indexing.service.ts index 1fd22586..d2e1b358 100644 --- a/backend/src/services/payroll-indexing.service.ts +++ b/backend/src/services/payroll-indexing.service.ts @@ -1,6 +1,6 @@ -import logger from '../utils/logger'; -import { PaginationParams, createPaginatedResult, PaginatedResult } from '../utils/pagination'; -import { SDSTransaction } from './sds.service'; +import logger from '../utils/logger.js'; +import { PaginationParams, createPaginatedResult, PaginatedResult } from '../utils/pagination.js'; +import { SDSTransaction } from './sds.service.js'; export interface PayrollMemoFormat { type: 'PAYROLL' | 'BONUS' | 'INVOICE' | 'OTHER'; diff --git a/backend/src/services/payroll-query.service.ts b/backend/src/services/payroll-query.service.ts index f80da578..64c416d1 100644 --- a/backend/src/services/payroll-query.service.ts +++ b/backend/src/services/payroll-query.service.ts @@ -1,12 +1,12 @@ -import { sdsClient, SDSTransaction, SDSTransactionFilter } from './sds.service'; +import { sdsClient, SDSTransaction, SDSTransactionFilter } from './sds.service.js'; import { payrollIndexingService, PayrollIndexQuery, PayrollTransaction, PayrollAggregation, -} from './payroll-indexing.service'; -import { parsePaginationParams, PaginationParams, PaginatedResult } from '../utils/pagination'; -import logger from '../utils/logger'; +} from './payroll-indexing.service.js'; +import { parsePaginationParams, PaginationParams, PaginatedResult } from '../utils/pagination.js'; +import logger from '../utils/logger.js'; export interface PayrollQueryOptions { useCache?: boolean; diff --git a/backend/src/services/payrollAuditService.ts b/backend/src/services/payrollAuditService.ts index 5481397f..e6686c15 100644 --- a/backend/src/services/payrollAuditService.ts +++ b/backend/src/services/payrollAuditService.ts @@ -1,5 +1,5 @@ -import { pool } from '../config/database'; -import logger from '../utils/logger'; +import { pool } from '../config/database.js'; +import logger from '../utils/logger.js'; export type PayrollAuditAction = | 'run_created' diff --git a/backend/src/services/payrollBonusService.ts b/backend/src/services/payrollBonusService.ts index d735a1b6..e2dba1f4 100644 --- a/backend/src/services/payrollBonusService.ts +++ b/backend/src/services/payrollBonusService.ts @@ -1,5 +1,5 @@ -import { pool } from '../config/database'; -import logger from '../utils/logger'; +import { pool } from '../config/database.js'; +import logger from '../utils/logger.js'; export interface PayrollRun { id: number; diff --git a/backend/src/services/rateLimitService.ts b/backend/src/services/rateLimitService.ts index a678ca5f..9e265918 100644 --- a/backend/src/services/rateLimitService.ts +++ b/backend/src/services/rateLimitService.ts @@ -1,6 +1,6 @@ -import Redis from 'ioredis'; -import { config } from '../config/env'; -import logger from '../utils/logger'; +import { Redis } from 'ioredis'; +import { config } from '../config/env.js'; +import logger from '../utils/logger.js'; export interface RateLimitConfig { windowMs: number; @@ -52,7 +52,7 @@ class RedisClient { lazyConnect: true, }); - this.instance.on('error', (err) => { + this.instance.on('error', (err: Error) => { logger.error('Redis connection error', { error: err.message }); }); @@ -73,6 +73,7 @@ class RedisClient { export class RateLimitService { private redis: Redis | null; + private memoryStore: Map = new Map(); private useMemoryFallback: boolean = false; diff --git a/backend/src/services/sds.service.ts b/backend/src/services/sds.service.ts index af4c84a5..c9d2ee16 100644 --- a/backend/src/services/sds.service.ts +++ b/backend/src/services/sds.service.ts @@ -1,7 +1,7 @@ import axios, { AxiosInstance } from 'axios'; -import config from '../config'; -import logger from '../utils/logger'; -import { PaginationParams } from '../utils/pagination'; +import config from '../config/index.js'; +import logger from '../utils/logger.js'; +import { PaginationParams } from '../utils/pagination.js'; export interface SDSTransaction { id: string; diff --git a/backend/src/services/searchService.ts b/backend/src/services/searchService.ts index 0d972a8b..341b8532 100644 --- a/backend/src/services/searchService.ts +++ b/backend/src/services/searchService.ts @@ -1,5 +1,5 @@ import { Pool } from 'pg'; -import pool from '../config/database'; +import pool from '../config/database.js'; export interface SearchFilters { query?: string; diff --git a/backend/src/services/socketService.ts b/backend/src/services/socketService.ts index bf0d879e..676d2d77 100644 --- a/backend/src/services/socketService.ts +++ b/backend/src/services/socketService.ts @@ -1,6 +1,6 @@ import { Server as SocketIOServer, Socket } from 'socket.io'; import { Server as HttpServer } from 'http'; -import { config } from '../config/env'; +import { config } from '../config/env.js'; let io: SocketIOServer | null = null; diff --git a/backend/src/services/taxService.ts b/backend/src/services/taxService.ts index 4fc29927..0d7db5c9 100644 --- a/backend/src/services/taxService.ts +++ b/backend/src/services/taxService.ts @@ -1,5 +1,5 @@ import { Pool } from 'pg'; -import pool from '../config/database'; +import pool from '../config/database.js'; export interface TaxRule { id: number; diff --git a/backend/src/services/tenantConfigService.ts b/backend/src/services/tenantConfigService.ts index 13ce87ca..b980d893 100644 --- a/backend/src/services/tenantConfigService.ts +++ b/backend/src/services/tenantConfigService.ts @@ -1,5 +1,5 @@ import { Pool } from 'pg'; -import { pool } from '../config/database'; +import { pool } from '../config/database.js'; export interface TenantConfig { id: number; diff --git a/backend/src/services/trustlineService.ts b/backend/src/services/trustlineService.ts index e0931fb4..8c8db2f1 100644 --- a/backend/src/services/trustlineService.ts +++ b/backend/src/services/trustlineService.ts @@ -1,6 +1,6 @@ import { Asset, TransactionBuilder, Operation, Keypair } from '@stellar/stellar-sdk'; -import { StellarService } from './stellarService'; -import { pool } from '../config/database'; +import { StellarService } from './stellarService.js'; +import { pool } from '../config/database.js'; export type TrustlineStatus = 'none' | 'pending' | 'established'; diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts index 02cbb048..11fe0ba5 100644 --- a/backend/src/services/webhook.service.ts +++ b/backend/src/services/webhook.service.ts @@ -12,7 +12,7 @@ export interface WebhookSubscription { const subscriptions: WebhookSubscription[] = []; export class WebhookService { - static async subscribe(url: string, secret: string, events: string[]) { + static async subscribe(url: string, secret: string, events: string[]): Promise { const subscription: WebhookSubscription = { id: Math.random().toString(36).substring(2, 11), url, @@ -23,11 +23,11 @@ export class WebhookService { return subscription; } - static listSubscriptions() { + static listSubscriptions(): WebhookSubscription[] { return subscriptions; } - static deleteSubscription(id: string) { + static deleteSubscription(id: string): boolean { const index = subscriptions.findIndex((s) => s.id === id); if (index !== -1) { subscriptions.splice(index, 1); @@ -36,7 +36,7 @@ export class WebhookService { return false; } - static async dispatch(eventType: string, payload: any) { + static async dispatch(eventType: string, payload: any): Promise { const relevantSubscriptions = subscriptions.filter( (s) => s.events.includes(eventType) || s.events.includes('*') ); @@ -61,7 +61,7 @@ export class WebhookService { await Promise.allSettled(dispatchPromises); } - private static generateSignature(payload: string, secret: string, timestamp: string) { + private static generateSignature(payload: string, secret: string, timestamp: string): string { const message = `${timestamp}.${payload}`; return CryptoJS.HmacSHA256(message, secret).toString(CryptoJS.enc.Hex); } @@ -72,7 +72,7 @@ export class WebhookService { headers: any, retries = 3, delay = 1000 - ) { + ): Promise { try { await axios.post(url, data, { headers, timeout: 5000 }); } catch (error) { diff --git a/backend/src/stellar/client.ts b/backend/src/stellar/client.ts index 46db27b5..7568c265 100644 --- a/backend/src/stellar/client.ts +++ b/backend/src/stellar/client.ts @@ -1,20 +1,39 @@ -/** - * Backward-compatible shims that delegate to the shared HorizonService singleton. - * Existing code can continue importing from "./client" without changes. - */ -import { horizonService } from "./horizonService"; import { Horizon } from "@stellar/stellar-sdk"; -import { NetworkConfig } from "./network"; +import { getNetworkConfig, NetworkConfig } from './network.js'; + +let cachedServer: Horizon.Server | null = null; +let cachedConfig: NetworkConfig | null = null; +/** + * Returns a cached Horizon server instance configured for the active + * Stellar network. The instance is created once and reused across calls. + */ export function getStellarServer(): Horizon.Server { - return horizonService.getServer(); + if (!cachedServer) { + const config = getNetworkConfig(); + cachedServer = new Horizon.Server(config.horizonUrl); + cachedConfig = config; + } + return cachedServer; } +/** + * Returns the resolved network configuration (network name, passphrase, + * and Horizon URL) for the currently active Stellar environment. + */ export function getActiveNetworkConfig(): NetworkConfig { - return horizonService.getConfig(); + if (!cachedConfig) { + cachedConfig = getNetworkConfig(); + } + return cachedConfig; } +/** + * Clears the cached server and config so the next call to + * `getStellarServer()` or `getActiveNetworkConfig()` re-reads + * the environment. Useful for tests or runtime network switching. + */ export function resetClient(): void { - horizonService.reset(); + cachedServer = null; + cachedConfig = null; } - diff --git a/backend/src/stellar/connectionTest.ts b/backend/src/stellar/connectionTest.ts index 7abf9b72..16209712 100644 --- a/backend/src/stellar/connectionTest.ts +++ b/backend/src/stellar/connectionTest.ts @@ -1,4 +1,4 @@ -import { getStellarServer, getActiveNetworkConfig } from "./client"; +import { getStellarServer, getActiveNetworkConfig } from './client.js'; export interface ConnectionTestResult { connected: boolean; diff --git a/backend/src/stellar/horizonService.ts b/backend/src/stellar/horizonService.ts deleted file mode 100644 index 6960c017..00000000 --- a/backend/src/stellar/horizonService.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Horizon } from "@stellar/stellar-sdk"; -import { getNetworkConfig, NetworkConfig, StellarNetwork } from "./network"; - -export interface HealthCheckResult { - healthy: boolean; - network: StellarNetwork; - horizonUrl: string; - latencyMs: number; - ledgerSequence?: number; - error?: string; -} - -interface RetryOptions { - maxAttempts?: number; - baseDelayMs?: number; - maxDelayMs?: number; -} - -const DEFAULT_RETRY: Required = { - maxAttempts: 3, - baseDelayMs: 500, - maxDelayMs: 5000, -}; - -/** - * HorizonService abstracts the Stellar Horizon client into a reusable service - * module. It handles: - * - Configuration switching between Testnet and Mainnet via env vars - * - Lazy-initialised cached Horizon.Server instance - * - Exponential-backoff retry logic for transient network errors - * - Connection health checks that can be run on service startup - */ -export class HorizonService { - private server: Horizon.Server | null = null; - private config: NetworkConfig | null = null; - private retryOptions: Required; - - constructor(retryOptions: RetryOptions = {}) { - this.retryOptions = { ...DEFAULT_RETRY, ...retryOptions }; - } - - // ─── Config & Server ──────────────────────────────────────────────────────── - - /** - * Returns the resolved network configuration, reading from env vars. - * Result is cached after the first call. - */ - getConfig(): NetworkConfig { - if (!this.config) { - this.config = getNetworkConfig(); - } - return this.config; - } - - /** - * Returns the cached Horizon.Server instance, creating it on first access. - */ - getServer(): Horizon.Server { - if (!this.server) { - const { horizonUrl } = this.getConfig(); - this.server = new Horizon.Server(horizonUrl); - } - return this.server; - } - - /** - * Clears the cached server and config so the next call re-reads the - * environment. Useful for tests or runtime environment switching. - */ - reset(): void { - this.server = null; - this.config = null; - } - - // ─── Retry Logic ──────────────────────────────────────────────────────────── - - /** - * Executes an async operation with exponential-backoff retry logic for - * transient network errors. Permanent errors (4xx) are NOT retried. - */ - async withRetry(operation: () => Promise): Promise { - const { maxAttempts, baseDelayMs, maxDelayMs } = this.retryOptions; - let lastError: unknown; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await operation(); - } catch (err: unknown) { - lastError = err; - - // If it's an HTTP error we can inspect the status code - const status = (err as { response?: { status?: number } })?.response?.status; - - // Don't retry on permanent client errors (400–499) - if (status !== undefined && status >= 400 && status < 500) { - throw err; - } - - if (attempt < maxAttempts) { - const delay = Math.min( - baseDelayMs * Math.pow(2, attempt - 1), - maxDelayMs - ); - console.warn( - `[HorizonService] attempt ${attempt}/${maxAttempts} failed. Retrying in ${delay}ms…` - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - } - - throw lastError; - } - - // ─── Health Checks ─────────────────────────────────────────────────────────── - - /** - * Performs a health check against the configured Horizon server by fetching - * fee stats. Returns latency and ledger info on success, or an error on - * failure. Intended to be called during service startup. - */ - async healthCheck(): Promise { - const config = this.getConfig(); - const server = this.getServer(); - const start = Date.now(); - - try { - const feeStats = await server.feeStats(); - return { - healthy: true, - network: config.network, - horizonUrl: config.horizonUrl, - latencyMs: Date.now() - start, - ledgerSequence: Number(feeStats.last_ledger), - }; - } catch (err) { - return { - healthy: false, - network: config.network, - horizonUrl: config.horizonUrl, - latencyMs: Date.now() - start, - error: err instanceof Error ? err.message : "Unknown connection error", - }; - } - } - - /** - * Runs a health check on startup and logs the result. Throws if the server - * is unreachable, so the application can exit cleanly instead of silently - * operating in a degraded state. - * - * @param throwOnFailure Set to false to warn but allow the app to continue. - */ - async checkOnStartup(throwOnFailure = true): Promise { - console.log("[HorizonService] Running startup health check…"); - const result = await this.healthCheck(); - - if (result.healthy) { - console.log( - `[HorizonService] Connected to Stellar ${result.network} ` + - `(ledger ${result.ledgerSequence ?? "n/a"}, latency ${result.latencyMs}ms)` - ); - } else { - const msg = - `[HorizonService] Horizon health check failed: ${result.error}` + - ` (url: ${result.horizonUrl}, network: ${result.network})`; - - if (throwOnFailure) { - throw new Error(msg); - } - console.warn(msg); - } - - return result; - } -} - -// ─── Singleton Export ──────────────────────────────────────────────────────── -// A default shared instance for convenience. Services that need custom retry -// behaviour can instantiate HorizonService directly. -export const horizonService = new HorizonService(); diff --git a/backend/src/stellar/index.ts b/backend/src/stellar/index.ts index 916ea768..9c2de537 100644 --- a/backend/src/stellar/index.ts +++ b/backend/src/stellar/index.ts @@ -2,21 +2,15 @@ export { StellarNetwork, getNetworkConfig, type NetworkConfig, -} from "./network"; +} from './network.js'; export { getStellarServer, getActiveNetworkConfig, resetClient, -} from "./client"; +} from './client.js'; export { testConnection, type ConnectionTestResult, -} from "./connectionTest"; - -export { - HorizonService, - horizonService, - type HealthCheckResult, -} from "./horizonService"; +} from './connectionTest.js'; diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts index d3624b82..aa9c1280 100644 --- a/backend/src/types/auth.ts +++ b/backend/src/types/auth.ts @@ -3,14 +3,14 @@ export type UserRole = 'EMPLOYER' | 'EMPLOYEE'; export interface JWTPayload { id: number; walletAddress: string; + email: string; organizationId: number | null; role: UserRole; } declare global { namespace Express { - interface Request { - user?: JWTPayload; - } + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface User extends JWTPayload { } } } diff --git a/backend/src/utils/contractValidator.ts b/backend/src/utils/contractValidator.ts new file mode 100644 index 00000000..c664a7e2 --- /dev/null +++ b/backend/src/utils/contractValidator.ts @@ -0,0 +1,79 @@ +/** + * Contract Validator Utility + * Validates Soroban contract entries for the Contract Address Registry API + */ + +export interface ContractEntry { + contractId: string; + network: string; + contractType: string; + version: string; + deployedAt: number; +} + +export interface ValidationResult { + isValid: boolean; + errors: string[]; +} + +/** + * Validates a Stellar contract address format + * Format: C followed by exactly 56 alphanumeric characters + */ +export function validateContractId(contractId: string): boolean { + const stellarContractRegex = /^C[A-Z0-9]{56}$/; + return stellarContractRegex.test(contractId); +} + +/** + * Validates network value + */ +export function validateNetwork(network: string): boolean { + return network === 'testnet' || network === 'mainnet'; +} + +/** + * Validates deployedAt ledger sequence + */ +export function validateDeployedAt(deployedAt: number): boolean { + return Number.isInteger(deployedAt) && deployedAt > 0; +} + +/** + * Validates a complete contract entry + */ +export function validateContractEntry(entry: Partial): ValidationResult { + const errors: string[] = []; + + // Check required fields + if (!entry.contractId) { + errors.push('Missing required field: contractId'); + } else if (!validateContractId(entry.contractId)) { + errors.push(`Invalid contractId format: ${entry.contractId}. Must be C followed by 56 alphanumeric characters`); + } + + if (!entry.network) { + errors.push('Missing required field: network'); + } else if (!validateNetwork(entry.network)) { + errors.push(`Invalid network value: ${entry.network}. Must be "testnet" or "mainnet"`); + } + + if (!entry.contractType) { + errors.push('Missing required field: contractType'); + } + + if (!entry.version) { + errors.push('Missing required field: version'); + } + + if (entry.deployedAt === undefined || entry.deployedAt === null) { + errors.push('Missing required field: deployedAt'); + } else if (!validateDeployedAt(entry.deployedAt)) { + errors.push(`Invalid deployedAt value: ${entry.deployedAt}. Must be a positive integer`); + } + + return { + isValid: errors.length === 0, + errors + }; +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 0ea20fe5..2fbaf83b 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -1,5 +1,3 @@ -import * as Sentry from '@sentry/node'; - enum LogLevel { DEBUG = 0, INFO = 1, @@ -57,15 +55,6 @@ export class Logger { if (this.level <= LogLevel.ERROR) { const errorData = error instanceof Error ? error.message : error; console.error(this.formatMessage('ERROR', message, errorData)); - - // Capture Sentry Exceptions - if (error instanceof Error) { - Sentry.captureException(error, { extra: { contextMessage: message } }); - } else if (error) { - Sentry.captureMessage(`${message}: ${typeof error === 'object' ? JSON.stringify(error) : error}`, 'error'); - } else { - Sentry.captureMessage(message, 'error'); - } } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5df745e..f40cd5e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@types/crypto-js": "^4.2.2", + "@types/jest": "^30.0.0", "@types/lodash": "^4.17.21", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -55,6 +56,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-x": "^2.3.11", + "fast-check": "^4.5.3", "glob": "^13.0.0", "globals": "^16.5.0", "husky": "^9.1.7", @@ -64,7 +66,8 @@ "typescript-eslint": "^8.48.1", "vite": "^7.2.6", "vite-plugin-node-polyfills": "^0.24.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "vitest": "^4.0.18" } }, "node_modules/@albedo-link/intent": { @@ -1242,6 +1245,138 @@ "node": "20 || >=22" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas/node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "license": "MIT", @@ -3671,6 +3806,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "license": "MIT", @@ -3748,6 +3894,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -3758,6 +3911,44 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -3814,6 +4005,13 @@ "@types/react-router": "*" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "license": "MIT" @@ -3843,6 +4041,23 @@ "@types/node": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "dev": true, @@ -4093,6 +4308,127 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wallet-standard/base": { "version": "1.1.0", "license": "Apache-2.0", @@ -4601,6 +4937,16 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -5141,6 +5487,16 @@ "big-integer": "1.6.36" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "license": "MIT", @@ -5171,6 +5527,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "license": "MIT", @@ -6030,6 +6402,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -6467,12 +6846,63 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/eyes": { "version": "0.1.8", "engines": { "node": "> 0.1.90" } }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -7365,19 +7795,312 @@ } } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/js-sha256": { - "version": "0.11.1", - "license": "MIT" - }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-sha256": { + "version": "0.11.1", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -8348,6 +9071,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ofetch": { "version": "1.5.1", "license": "MIT", @@ -8517,6 +9251,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbkdf2": { "version": "3.1.5", "dev": true, @@ -8676,6 +9417,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/process": { "version": "0.11.10", "dev": true, @@ -8759,6 +9535,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/pushdata-bitcoin": { "version": "1.0.1", "license": "MIT", @@ -9718,6 +10511,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "dev": true, @@ -9729,6 +10529,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "dev": true, @@ -9850,6 +10660,43 @@ "node": ">= 10.x" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-browserify": { "version": "3.0.0", "license": "MIT", @@ -10033,6 +10880,23 @@ "version": "4.12.2", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "dev": true, @@ -10075,6 +10939,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "license": "MIT", @@ -10753,6 +11627,97 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "dev": true, @@ -10814,6 +11779,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wif": { "version": "5.0.0", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 3f39e719..1b8c72ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@types/crypto-js": "^4.2.2", + "@types/jest": "^30.0.0", "@types/lodash": "^4.17.21", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -58,6 +59,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-x": "^2.3.11", + "fast-check": "^4.5.3", "glob": "^13.0.0", "globals": "^16.5.0", "husky": "^9.1.7", @@ -67,7 +69,8 @@ "typescript-eslint": "^8.48.1", "vite": "^7.2.6", "vite-plugin-node-polyfills": "^0.24.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "vitest": "^4.0.18" }, "lint-staged": { "src/**/*.{ts,tsx}": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1410bc4f..16bda55c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { Routes, Route } from 'react-router-dom'; +import { useEffect } from 'react'; import Home from './pages/Home'; import Debugger from './pages/Debugger'; import PayrollScheduler from './pages/PayrollScheduler'; @@ -11,15 +12,24 @@ import Settings from './pages/Settings'; import CustomReportBuilder from './pages/CustomReportBuilder'; import CrossAssetPayment from './pages/CrossAssetPayment'; import TransactionHistory from './pages/TransactionHistory'; +import RevenueSplitDashboard from './pages/RevenueSplitDashboard'; import EmployeePortal from './pages/EmployeePortal'; import Login from './pages/Login'; import AuthCallback from './pages/AuthCallback'; import { useTranslation } from 'react-i18next'; +import { contractService } from './services/contracts'; function App() { const { t } = useTranslation(); + // Initialize contract service on app startup + useEffect(() => { + contractService.initialize().catch((error) => { + console.error('Failed to initialize contract service:', error); + }); + }, []); + return ( }> @@ -153,6 +163,14 @@ function App() { } /> + {}} />}> + + + } + /> } /> } /> diff --git a/frontend/src/components/AppNav.tsx b/frontend/src/components/AppNav.tsx index c7f12aa3..b8d98552 100644 --- a/frontend/src/components/AppNav.tsx +++ b/frontend/src/components/AppNav.tsx @@ -11,6 +11,7 @@ import { ShieldAlert, Menu, X, + PieChart, } from 'lucide-react'; import { Avatar } from './Avatar'; @@ -126,6 +127,23 @@ const AppNav: React.FC = () => { History + + `flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition ${ + isActive + ? 'text-(--accent) bg-white/5' + : 'text-(--muted) hover:bg-white/10 hover:text-white' + }` + } + onClick={() => setMobileOpen(false)} + > + + + + Revenue Split + +
; + +function toRecipientStatus( + status: PayrollRecipientStatus['status'] +): 'pending' | 'confirmed' | 'failed' { + if (status === 'completed') return 'confirmed'; + if (status === 'failed') return 'failed'; + return 'pending'; +} + +function getEmployeeName(recipient: PayrollRecipientStatus): string { + const fullName = + `${recipient.employee_first_name ?? ''} ${recipient.employee_last_name ?? ''}`.trim(); + return fullName || recipient.employee_email || `Employee #${recipient.employee_id}`; +} + +function findRunTxHash(summary?: PayrollRunSummary): string | null { + if (!summary) return null; + const txHash = summary.items.find((item) => Boolean(item.tx_hash))?.tx_hash; + return txHash || null; +} + +function normalizeConfirmationPayload(payload: unknown): { + batchId: string | null; + confirmations: number | null; +} { + if (!payload || typeof payload !== 'object') { + return { batchId: null, confirmations: null }; + } + + const record = payload as Record; + const batchId = + (record.batchId as string | undefined) || + (record.batch_id as string | undefined) || + (record.runId as string | undefined) || + null; + + const countRaw = + record.confirmations ?? record.confirmationCount ?? record.confirmed ?? record.count ?? null; + + const count = + typeof countRaw === 'number' + ? countRaw + : typeof countRaw === 'string' + ? Number.parseInt(countRaw, 10) + : null; + + return { + batchId, + confirmations: Number.isFinite(count) ? count : null, + }; +} + +export function BulkPaymentStatusTracker({ organizationId }: BulkPaymentStatusTrackerProps) { + const [runs, setRuns] = useState([]); + const [summaries, setSummaries] = useState>({}); + const [expandedRunId, setExpandedRunId] = useState(null); + const [confirmations, setConfirmations] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [isRetryingBatchId, setIsRetryingBatchId] = useState(null); + const [error, setError] = useState(null); + + const { notifyError, notifySuccess } = useNotification(); + const { socket } = useSocket(); + const { address } = useWallet(); + const { sign } = useWalletSigning(); + + const loadRuns = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const payload = await fetchPayrollRuns(organizationId, 1, 20); + setRuns(payload.data); + } catch (loadError) { + const message = loadError instanceof Error ? loadError.message : 'Failed to load bulk runs'; + setError(message); + notifyError('Bulk payment load failed', message); + } finally { + setIsLoading(false); + } + }, [notifyError, organizationId]); + + useEffect(() => { + void loadRuns(); + }, [loadRuns]); + + const loadSummary = useCallback( + async (runId: number) => { + if (summaries[runId]) return; + try { + const summary = await fetchPayrollRunSummary(runId); + setSummaries((prev) => ({ ...prev, [runId]: summary })); + } catch (summaryError) { + const message = + summaryError instanceof Error + ? summaryError.message + : 'Failed to load per-recipient status'; + notifyError('Failed to load batch details', message); + } + }, + [notifyError, summaries] + ); + + useEffect(() => { + if (!socket) return; + + const onBulkConfirmation = (payload: unknown) => { + const normalized = normalizeConfirmationPayload(payload); + if (!normalized.batchId || normalized.confirmations === null) return; + setConfirmations((prev) => ({ + ...prev, + [normalized.batchId as string]: normalized.confirmations as number, + })); + }; + + socket.on('bulk:confirmation', onBulkConfirmation); + socket.on('bulk_payment:confirmation', onBulkConfirmation); + + runs.forEach((run) => { + socket.emit('subscribe:bulk', { batchId: run.batch_id }); + }); + + return () => { + socket.off('bulk:confirmation', onBulkConfirmation); + socket.off('bulk_payment:confirmation', onBulkConfirmation); + runs.forEach((run) => { + socket.emit('unsubscribe:bulk', { batchId: run.batch_id }); + }); + }; + }, [runs, socket]); + + const handleToggleExpand = async (runId: number) => { + if (expandedRunId === runId) { + setExpandedRunId(null); + return; + } + setExpandedRunId(runId); + await loadSummary(runId); + }; + + const handleRetry = async (run: PayrollRunRecord) => { + if (!address) { + notifyError('Wallet required', 'Connect a wallet before retrying failed recipients.'); + return; + } + + const summary = summaries[run.id]; + const hasFailedRecipients = summary?.items.some((item) => item.status === 'failed'); + if (!hasFailedRecipients) return; + + setIsRetryingBatchId(run.batch_id); + try { + await contractService.initialize(); + const contractId = + contractService.getContractId('bulk_payment', 'testnet') || + (import.meta.env.VITE_BULK_PAYMENT_CONTRACT_ID as string | undefined); + + if (!contractId) { + throw new Error('Bulk payment contract ID is unavailable.'); + } + + const { txHash } = await retryFailedBatch({ + contractId, + batchId: run.batch_id, + sourceAddress: address, + signTransaction: sign, + }); + + notifySuccess('Retry submitted', `Batch ${run.batch_id} was re-invoked. TX: ${txHash}`); + await loadSummary(run.id); + } catch (retryError) { + const message = retryError instanceof Error ? retryError.message : 'Retry failed'; + notifyError('Retry failed', message); + } finally { + setIsRetryingBatchId(null); + } + }; + + const rows = useMemo(() => { + return runs.map((run) => { + const summary = summaries[run.id]; + const employeeCount = summary?.summary.total_employees ?? 0; + const txHash = findRunTxHash(summary); + const confirmationCount = confirmations[run.batch_id] ?? 0; + const hasFailedRecipients = summary?.items.some((item) => item.status === 'failed') ?? false; + + return { + run, + summary, + employeeCount, + txHash, + confirmationCount, + hasFailedRecipients, + }; + }); + }, [confirmations, runs, summaries]); + + return ( +
+
+

Bulk Payment Status Tracker

+ +
+ + {isLoading ?

Loading bulk payroll runs...

: null} + {error ?

{error}

: null} + + {!isLoading && rows.length === 0 ? ( +

No payroll batch runs found.

+ ) : ( +
+ + + + + + + + + + + + + + {rows.map( + ({ + run, + summary, + employeeCount, + txHash, + confirmationCount, + hasFailedRecipients, + }) => ( + { + void handleToggleExpand(run.id); + }} + onRetry={() => { + void handleRetry(run); + }} + /> + ) + )} + +
BatchStatusEmployeesTotalConfirmationsTx HashActions
+
+ )} +
+ ); +} + +interface FragmentRowProps { + run: PayrollRunRecord; + summary?: PayrollRunSummary; + employeeCount: number; + txHash: string | null; + confirmationCount: number; + expanded: boolean; + retrying: boolean; + hasFailedRecipients: boolean; + onToggleExpand: () => void; + onRetry: () => void; +} + +function FragmentRow({ + run, + summary, + employeeCount, + txHash, + confirmationCount, + expanded, + retrying, + hasFailedRecipients, + onToggleExpand, + onRetry, +}: FragmentRowProps) { + return ( + <> + + {run.batch_id} + {run.status} + {employeeCount} + + {run.total_amount} {run.asset_code} + + {confirmationCount} + + {txHash ? ( + + {txHash.slice(0, 10)}... + + ) : ( + N/A + )} + + +
+ + {hasFailedRecipients ? ( + + ) : null} +
+ + + {expanded ? ( + + + {!summary ? ( +

Loading recipient statuses...

+ ) : ( +
+ {summary.items.map((recipient) => ( +
+ {getEmployeeName(recipient)} + + {recipient.amount} {run.asset_code} + + {toRecipientStatus(recipient.status)} +
+ ))} +
+ )} + + + ) : null} + + ); +} diff --git a/frontend/src/components/ContractUpgradeTab.tsx b/frontend/src/components/ContractUpgradeTab.tsx new file mode 100644 index 00000000..b9f89b99 --- /dev/null +++ b/frontend/src/components/ContractUpgradeTab.tsx @@ -0,0 +1,392 @@ +/** + * ContractUpgradeTab + * + * Admin panel section for managing Soroban contract upgrades. + * Displays all registered contracts from the backend registry and + * lets the admin initiate an upgrade flow via UpgradeConfirmModal. + * + * Data flow: + * 1. On mount, fetch the contract list from /api/v1/contracts. + * 2. Each contract card shows: name, contract ID, current WASM hash, + * version, network, and last upgraded timestamp. + * 3. "Upgrade" button opens UpgradeConfirmModal for the selected contract. + * 4. On upgrade completion, the contract list is re-fetched to reflect + * the new WASM hash without a full page reload. + * + * Space complexity: O(n) where n = number of registered contracts. + * Time complexity: O(n) for initial render; O(1) for subsequent upgrades. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Code2, + RefreshCw, + ArrowUpCircle, + Clock, + CheckCircle2, + AlertTriangle, + ChevronDown, + ChevronUp, + ExternalLink, +} from 'lucide-react'; +import { + type ContractRecord, + type UpgradeLog, + fetchContracts, + fetchUpgradeLogs, +} from '../services/contractUpgrade'; +import UpgradeConfirmModal from './UpgradeConfirmModal'; +import { useNotification } from '../hooks/useNotification'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface ContractUpgradeTabProps { + /** Connected admin wallet address — passed to the modal as initiatedBy */ + adminAddress: string; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Compact hash display: first 8 + last 6 chars with monospace styling */ +function HashBadge({ hash, full = false }: { hash: string; full?: boolean }) { + return ( + + {full ? hash : `${hash.slice(0, 8)}…${hash.slice(-6)}`} + + ); +} + +/** Network pill badge */ +function NetworkBadge({ network }: { network: string }) { + const isMainnet = network === 'MAINNET'; + return ( + + {network} + + ); +} + +/** Upgrade log status badge */ +function StatusBadge({ status }: { status: UpgradeLog['status'] }) { + const colorMap: Record = { + pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30', + simulated: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + confirmed: 'bg-accent/10 text-accent border-accent/30', + executing: 'bg-accent/10 text-accent border-accent/30', + completed: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/30', + failed: 'bg-red-500/10 text-red-500 border-red-500/30', + cancelled: 'bg-muted/10 text-muted border-muted/30', + }; + return ( + + {status} + + ); +} + +// --------------------------------------------------------------------------- +// ContractCard +// --------------------------------------------------------------------------- + +interface ContractCardProps { + contract: ContractRecord; + onUpgrade: (contract: ContractRecord) => void; +} + +function ContractCard({ contract, onUpgrade }: ContractCardProps) { + const [showHistory, setShowHistory] = useState(false); + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + + async function loadHistory() { + if (logs.length > 0) { + setShowHistory((v) => !v); + return; + } + setLogsLoading(true); + try { + const result = await fetchUpgradeLogs(contract.id, 1, 5); + setLogs(result.data); + setShowHistory(true); + } catch { + // Silently skip history if unavailable + } finally { + setLogsLoading(false); + } + } + + return ( +
+ {/* Card header */} +
+
+
+

{contract.name}

+ + v{contract.version} +
+ {contract.description && ( +

{contract.description}

+ )} +
+ + +
+ + {/* Contract details grid */} +
+
+
+

+ Contract ID +

+
+ {contract.contract_id} + + + +
+
+ +
+

+ Current WASM Hash +

+ +
+
+ + {contract.last_upgraded_at && ( +
+ + + Last upgraded:{' '} + + {new Date(contract.last_upgraded_at).toLocaleString()} + + {contract.last_upgraded_by && ( + <> + {' by '} + {contract.last_upgraded_by.slice(0, 8)}… + + )} + +
+ )} +
+ + {/* Upgrade history toggle */} +
+ + + {showHistory && ( +
+ {logs.length === 0 ? ( +

No upgrade history yet.

+ ) : ( +
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + ))} + +
DateNew HashStatusTX
+ {new Date(log.created_at).toLocaleDateString()} + + + + + + {log.tx_hash ? ( + + {log.tx_hash.slice(0, 8)}… + + ) : ( + + )} +
+
+ )} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// ContractUpgradeTab (main export) +// --------------------------------------------------------------------------- + +export default function ContractUpgradeTab({ adminAddress }: ContractUpgradeTabProps) { + const { notifyError } = useNotification(); + + const [contracts, setContracts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedContract, setSelectedContract] = useState(null); + + const loadContracts = useCallback(async () => { + setLoading(true); + try { + const data = await fetchContracts(); + setContracts(data); + } catch (err: unknown) { + notifyError( + 'Load Failed', + err instanceof Error ? err.message : 'Failed to load contract registry.' + ); + } finally { + setLoading(false); + } + }, [notifyError]); + + useEffect(() => { + void loadContracts(); + }, [loadContracts]); + + /** Called when an upgrade completes — refresh the contract list in-place. */ + function handleUpgradeComplete(newWasmHash: string) { + if (!selectedContract) return; + // Optimistic update: reflect new hash immediately without a network round-trip + setContracts((prev) => + prev.map((c) => + c.id === selectedContract.id + ? { ...c, current_wasm_hash: newWasmHash, last_upgraded_at: new Date().toISOString() } + : c + ) + ); + setSelectedContract(null); + // Background refresh to sync version and last_upgraded_by from server + void loadContracts(); + } + + return ( +
+ {/* Section header */} +
+

+ Contract Registry +

+ +
+ + {/* Description */} +

+ Manage deployed Soroban smart contract upgrades. Each upgrade triggers a simulation, + multi-step confirmation, and on-chain execution followed by automated data migration. +

+ + {/* No admin address warning */} + {!adminAddress && ( +
+ +

+ Connect your admin wallet to initiate contract upgrades. +

+
+ )} + + {/* Contract list */} + {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ) : contracts.length === 0 ? ( +
+ +

No contracts found in registry.

+

+ Run the database migration to seed the contract registry. +

+
+ ) : ( +
+ {contracts.map((contract) => ( + adminAddress && setSelectedContract(c)} + /> + ))} +
+ )} + + {/* Multi-step upgrade modal */} + {selectedContract && adminAddress && ( + setSelectedContract(null)} + onUpgradeComplete={handleUpgradeComplete} + /> + )} +
+ ); +} diff --git a/frontend/src/components/UpgradeConfirmModal.tsx b/frontend/src/components/UpgradeConfirmModal.tsx new file mode 100644 index 00000000..d5653f95 --- /dev/null +++ b/frontend/src/components/UpgradeConfirmModal.tsx @@ -0,0 +1,859 @@ +/** + * UpgradeConfirmModal + * + * A focused multi-step modal that guides the admin through the full + * Soroban contract upgrade lifecycle: + * + * Step 1 — INPUT Enter and validate new WASM hash + * Step 2 — SIMULATING Loading state while Soroban RPC simulates + * Step 3 — REVIEW Show simulation diff, cost, warnings + * Step 4 — AUTHORIZE Admin secret key entry + final confirm + * Step 5 — EXECUTING Progress bar + migration steps + * Step 6 — DONE/FAILED Terminal state with tx hash or error + * + * State transitions are represented as a discriminated union so + * TypeScript enforces that every render branch accesses only the + * data that exists for that step (no optional field sprawl). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + X, + ChevronRight, + Loader2, + CheckCircle2, + XCircle, + AlertTriangle, + ShieldCheck, + ArrowRight, + Copy, + RefreshCw, +} from 'lucide-react'; +import { + type ContractRecord, + type UpgradeSimulationResult, + type UpgradeLog, + type MigrationStep, + validateWasmHash, + simulateUpgrade, + executeUpgrade, + fetchUpgradeStatus, + cancelUpgrade, +} from '../services/contractUpgrade'; +import { useNotification } from '../hooks/useNotification'; + +// --------------------------------------------------------------------------- +// Style constants (consistent with AdminPanel.tsx) +// --------------------------------------------------------------------------- + +const INPUT_CLASS = + 'w-full bg-black/20 border border-hi rounded-xl p-4 text-text outline-none ' + + 'focus:border-accent/50 focus:bg-accent/5 transition-all font-mono text-sm'; + +const LABEL_CLASS = 'block text-xs font-bold uppercase tracking-widest text-muted mb-2 ml-1'; + +// --------------------------------------------------------------------------- +// Modal step discriminated union +// --------------------------------------------------------------------------- + +type ModalState = + | { step: 'input'; wasmHash: string; validating: boolean; validationError: string | null } + | { step: 'simulating' } + | { + step: 'review'; + upgradeLogId: number; + simulation: UpgradeSimulationResult; + wasmHash: string; + } + | { + step: 'authorize'; + upgradeLogId: number; + wasmHash: string; + adminSecret: string; + } + | { + step: 'executing'; + upgradeLogId: number; + txHash: string | null; + migrationSteps: MigrationStep[]; + overallStatus: UpgradeLog['status']; + } + | { step: 'done'; txHash: string; wasmHash: string } + | { step: 'failed'; error: string }; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Step progress indicator */ +function StepBreadcrumb({ current }: { current: number }) { + const steps = ['Input', 'Simulate', 'Review', 'Authorize', 'Execute']; + return ( +
+ {steps.map((label, i) => ( + + + {i + 1 < current ? '✓' : `${i + 1}.`} {label} + + {i < steps.length - 1 && } + + ))} +
+ ); +} + +/** Diff row showing old → new WASM hash */ +function HashDiff({ + label, + oldHash, + newHash, +}: { + label: string; + oldHash: string; + newHash: string; +}) { + return ( +
+ {label} +
+
+ + {oldHash} +
+
+ + + {newHash} +
+
+
+ ); +} + +/** Individual migration step row */ +function MigrationStepRow({ step }: { step: MigrationStep }) { + const icon = { + pending:
, + running: , + completed: , + failed: , + }[step.status]; + + const textColor = { + pending: 'text-muted', + running: 'text-text', + completed: 'text-emerald-400', + failed: 'text-red-400', + }[step.status]; + + return ( +
+
{icon}
+
+

{step.name}

+ {step.message &&

{step.message}

} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface UpgradeConfirmModalProps { + contract: ContractRecord; + /** Wallet address of the currently connected admin — used as initiatedBy */ + adminAddress: string; + onClose: () => void; + onUpgradeComplete: (newWasmHash: string) => void; +} + +// --------------------------------------------------------------------------- +// UpgradeConfirmModal +// --------------------------------------------------------------------------- + +export default function UpgradeConfirmModal({ + contract, + adminAddress, + onClose, + onUpgradeComplete, +}: UpgradeConfirmModalProps) { + const { notifySuccess, notifyError } = useNotification(); + + const [modal, setModal] = useState({ + step: 'input', + wasmHash: '', + validating: false, + validationError: null, + }); + + // Polling ref — cleared on unmount or when we reach a terminal step + const pollRef = useRef | null>(null); + + const clearPoll = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => () => clearPoll(), [clearPoll]); + + // ── Helpers ────────────────────────────────────────────────────────────── + + function currentStepIndex(): number { + const map: Record = { + input: 1, + simulating: 2, + review: 3, + authorize: 4, + executing: 5, + done: 5, + failed: 5, + }; + return map[modal.step]; + } + + // ── Step 1: validate & proceed to simulation ───────────────────────────── + + async function handleValidateAndSimulate() { + if (modal.step !== 'input') return; + const { wasmHash } = modal; + + if (!wasmHash.trim()) { + setModal((m) => + m.step === 'input' ? { ...m, validationError: 'WASM hash is required.' } : m + ); + return; + } + + // Client-side format pre-check to avoid unnecessary round-trip + if (!/^[0-9a-f]{64}$/i.test(wasmHash.trim())) { + setModal((m) => + m.step === 'input' + ? { ...m, validationError: 'WASM hash must be exactly 64 lowercase hex characters.' } + : m + ); + return; + } + + setModal((m) => (m.step === 'input' ? { ...m, validating: true, validationError: null } : m)); + + try { + // Validate against backend registry + on-chain check + const { valid, reason } = await validateWasmHash(contract.id, wasmHash.trim()); + if (!valid) { + setModal((m) => + m.step === 'input' + ? { ...m, validating: false, validationError: reason ?? 'Validation failed.' } + : m + ); + return; + } + } catch { + setModal((m) => + m.step === 'input' + ? { ...m, validating: false, validationError: 'Could not reach backend for validation.' } + : m + ); + return; + } + + // ── Proceed to simulation ──────────────────────────────────────────── + setModal({ step: 'simulating' }); + + try { + const { upgradeLogId, simulation } = await simulateUpgrade( + contract.id, + wasmHash.trim(), + adminAddress + ); + + setModal({ + step: 'review', + upgradeLogId, + simulation, + wasmHash: wasmHash.trim(), + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Simulation failed'; + setModal({ step: 'failed', error: message }); + } + } + + // ── Step 3: accept review, advance to authorize ────────────────────────── + + function handleAcceptReview() { + if (modal.step !== 'review') return; + setModal({ + step: 'authorize', + upgradeLogId: modal.upgradeLogId, + wasmHash: modal.wasmHash, + adminSecret: '', + }); + } + + // ── Step 4: execute the upgrade ────────────────────────────────────────── + + async function handleExecute() { + if (modal.step !== 'authorize') return; + const { upgradeLogId, wasmHash, adminSecret } = modal; + + if (!adminSecret.trim()) { + notifyError( + 'Missing secret', + 'Admin secret key is required to sign the upgrade transaction.' + ); + return; + } + + try { + const result = await executeUpgrade(upgradeLogId, adminSecret.trim()); + + setModal({ + step: 'executing', + upgradeLogId, + txHash: result.txHash ?? null, + migrationSteps: [], + overallStatus: 'executing', + }); + + // Poll migration progress every 3 seconds + pollRef.current = setInterval(() => { + void (async () => { + try { + const log = await fetchUpgradeStatus(upgradeLogId); + + setModal({ + step: 'executing', + upgradeLogId, + txHash: log.tx_hash, + migrationSteps: log.migration_steps, + overallStatus: log.status, + }); + + // Reached terminal state — stop polling + if (log.status === 'completed') { + clearPoll(); + notifySuccess( + 'Upgrade Complete', + 'Contract upgraded and migration finished successfully.' + ); + setModal({ step: 'done', txHash: log.tx_hash ?? result.txHash, wasmHash }); + onUpgradeComplete(wasmHash); + } else if (log.status === 'failed') { + clearPoll(); + notifyError('Upgrade Failed', log.error_message ?? 'Upgrade or migration failed.'); + setModal({ step: 'failed', error: log.error_message ?? 'Upgrade failed.' }); + } + } catch { + // Network blip — keep polling silently + } + })(); + }, 3_000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Execution failed'; + notifyError('Execution Failed', message); + setModal({ step: 'failed', error: message }); + } + } + + // ── Cancel (only valid for pre-execution states) ───────────────────────── + + async function handleCancel() { + if (modal.step === 'review' || modal.step === 'authorize') { + try { + const logId = modal.upgradeLogId; + await cancelUpgrade(logId); + } catch { + // Best-effort cancel; ignore errors + } + } + clearPoll(); + onClose(); + } + + // ── Copy to clipboard helper ───────────────────────────────────────────── + + function copyToClipboard(text: string) { + void navigator.clipboard.writeText(text).then(() => { + notifySuccess('Copied', 'Copied to clipboard.'); + }); + } + + // ── Backdrop click only closes in non-executing, non-simulating states ─── + + function handleBackdropClick() { + if (['executing', 'simulating'].includes(modal.step)) return; + void handleCancel(); + } + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( +
+
e.stopPropagation()} + > + {/* Modal header */} +
+
+

Upgrade Contract

+

{contract.name}

+
+ {!['executing', 'simulating'].includes(modal.step) && ( + + )} +
+ + {/* Step breadcrumb */} +
+ +
+ + {/* Step content */} +
+ {/* ── Step 1: INPUT ─────────────────────────────────────────── */} + {modal.step === 'input' && ( +
+ {/* Current contract state */} +
+

Current Deployed WASM Hash

+

+ {contract.current_wasm_hash} +

+
+ + Version: {contract.version} + + · + + Network: {contract.network} + + {contract.last_upgraded_at && ( + <> + · + + Last upgraded:{' '} + + {new Date(contract.last_upgraded_at).toLocaleDateString()} + + + + )} +
+
+ + {/* New WASM hash input */} +
+ + + setModal((m) => + m.step === 'input' + ? { + ...m, + wasmHash: e.target.value.toLowerCase().trim(), + validationError: null, + } + : m + ) + } + className={`${INPUT_CLASS} ${modal.validationError ? 'border-red-500/60' : ''}`} + placeholder="64-character hex SHA-256 of the new WASM bytecode" + spellCheck={false} + maxLength={64} + autoComplete="off" + /> + {modal.validationError && ( +

+ + {modal.validationError} +

+ )} +

+ Upload the WASM bytecode first via{' '} + + stellar contract upload + {' '} + to obtain the hash. +

+
+ + +
+ )} + + {/* ── Step 2: SIMULATING ────────────────────────────────────── */} + {modal.step === 'simulating' && ( +
+
+
+ +
+
+
+

Simulating Upgrade

+

+ Pre-flighting the transaction via Soroban RPC… +

+
+
+ )} + + {/* ── Step 3: REVIEW ────────────────────────────────────────── */} + {modal.step === 'review' && ( +
+ {/* Simulation status */} +
+ {modal.simulation.success ? ( + + ) : ( + + )} +
+

+ {modal.simulation.success ? 'Simulation Passed' : 'Simulation Failed'} +

+ {modal.simulation.error && ( +

{modal.simulation.error}

+ )} +
+
+ + {/* Warnings */} + {modal.simulation.warnings.length > 0 && ( +
+ {modal.simulation.warnings.map((w) => ( +
+ + {w} +
+ ))} +
+ )} + + {/* Hash diff */} + + + {/* Cost breakdown */} + {modal.simulation.success && ( +
+

Estimated Cost

+
+
+
Network Fee
+
+ {modal.simulation.estimatedFeeXlm} XLM +
+
+
+
CPU Instructions
+
+ {modal.simulation.cpuInstructions === 'N/A' + ? 'N/A' + : Number(modal.simulation.cpuInstructions).toLocaleString()} +
+
+
+
Memory
+
+ {modal.simulation.memoryBytes === 'N/A' + ? 'N/A' + : `${(Number(modal.simulation.memoryBytes) / 1024).toFixed(1)} KB`} +
+
+ {modal.simulation.latestLedger > 0 && ( +
+
Ledger
+
+ #{modal.simulation.latestLedger.toLocaleString()} +
+
+ )} +
+
+ )} + + {/* Actions */} +
+ + {modal.simulation.success && ( + + )} +
+
+ )} + + {/* ── Step 4: AUTHORIZE ─────────────────────────────────────── */} + {modal.step === 'authorize' && ( +
+ {/* Irreversibility warning */} +
+ +
+

This action is irreversible

+

+ The contract will be upgraded on-chain immediately after signing. Ensure you + have thoroughly reviewed the new WASM and simulation results. +

+
+
+ + {/* Compact diff reminder */} +
+

Upgrade Summary

+
+
+ From + + {contract.current_wasm_hash.slice(0, 24)}… + +
+
+ To + + {modal.wasmHash.slice(0, 24)}… + +
+
+
+ + {/* Admin secret key */} +
+ + + setModal((m) => + m.step === 'authorize' ? { ...m, adminSecret: e.target.value.trim() } : m + ) + } + className={INPUT_CLASS} + placeholder="S..." + autoComplete="off" + spellCheck={false} + /> +

+ Your secret key is used only to sign this transaction and is never stored. +

+
+ + {/* Actions */} +
+ + +
+
+ )} + + {/* ── Step 5: EXECUTING ─────────────────────────────────────── */} + {modal.step === 'executing' && ( +
+ {/* Transaction info */} + {modal.txHash && ( +
+

Transaction Hash

+
+ + {modal.txHash} + + +
+
+ )} + + {/* Overall status badge */} +
+ + Status: + + + {modal.overallStatus} + + {['executing', 'pending'].includes(modal.overallStatus) && ( + + )} +
+ + {/* Migration steps */} +
+
+

+ Post-Upgrade Migration +

+
+
+ {modal.migrationSteps.length === 0 ? ( +
+ + Waiting for migration to start… +
+ ) : ( + modal.migrationSteps.map((s) => ) + )} +
+
+
+ )} + + {/* ── Step 6a: DONE ─────────────────────────────────────────── */} + {modal.step === 'done' && ( +
+
+ +
+
+

Upgrade Complete

+

+ Contract and migration finished successfully. +

+
+
+

Transaction Hash

+
+ {modal.txHash} + +
+
+ +
+ )} + + {/* ── Step 6b: FAILED ───────────────────────────────────────── */} + {modal.step === 'failed' && ( +
+
+ +
+
+

Upgrade Failed

+

+ The upgrade did not complete successfully. +

+
+
+

{modal.error}

+
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useSorobanContract.ts b/frontend/src/hooks/useSorobanContract.ts new file mode 100644 index 00000000..7d2f5978 --- /dev/null +++ b/frontend/src/hooks/useSorobanContract.ts @@ -0,0 +1,208 @@ +import { useCallback, useState } from 'react'; +import { + BASE_FEE, + Contract, + Networks, + StrKey, + rpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { useNotification } from './useNotification'; +import { useWallet } from './useWallet'; +import { useWalletSigning } from './useWalletSigning'; +import { simulateTransaction } from '../services/transactionSimulation'; + +type SorobanNativeArg = string | number | bigint | boolean | null; + +type SorobanArg = SorobanNativeArg | xdr.ScVal; + +interface InvokeOptions { + method: string; + args?: SorobanArg[]; + parseResult?: (value: unknown) => TResult; + rpcUrl?: string; + networkPassphrase?: string; + fee?: string; + timeoutSeconds?: number; +} + +interface SorobanInvokeResult { + txHash: string; + value: TResult | null; + raw: unknown; +} + +interface UseSorobanContractState { + invoke: (options: InvokeOptions) => Promise>; + loading: boolean; + error: string | null; + result: SorobanInvokeResult | null; +} + +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_POLL_INTERVAL_MS = 1_500; +const DEFAULT_MAX_POLL_ATTEMPTS = 20; + +function getRpcUrl(override?: string): string { + if (override) return override.replace(/\/+$/, ''); + const envRpc = import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined; + return envRpc?.replace(/\/+$/, '') || 'https://soroban-testnet.stellar.org'; +} + +function getNetworkPassphrase(override?: string): string { + if (override) return override; + const network = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase(); + return network === 'MAINNET' ? Networks.PUBLIC : Networks.TESTNET; +} + +function isScVal(value: SorobanArg): value is xdr.ScVal { + return typeof value === 'object' && value !== null && 'switch' in value; +} + +function toScVal(arg: SorobanArg): xdr.ScVal { + if (isScVal(arg)) return arg; + return nativeToScVal(arg); +} + +function getResultValue(response: rpc.Api.GetTransactionResponse): unknown { + if (response.status !== rpc.Api.GetTransactionStatus.SUCCESS) return null; + const returnValue = response.returnValue; + if (!returnValue) return null; + return scValToNative(returnValue); +} + +function assertValidContractId(contractId: string): void { + if (!StrKey.isValidContract(contractId)) { + throw new Error('Invalid Soroban contract ID provided to useSorobanContract.'); + } +} + +function parseTypedResult( + raw: unknown, + parser?: (value: unknown) => TResult +): TResult | null { + if (raw == null) return null; + if (!parser) return raw as TResult; + + try { + return parser(raw); + } catch (error) { + const parserMessage = error instanceof Error ? error.message : 'Unknown parser error'; + throw new Error(`Unable to decode contract result: ${parserMessage}`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function useSorobanContract( + contractId: string +): UseSorobanContractState { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState | null>(null); + + const { address } = useWallet(); + const { sign } = useWalletSigning(); + const { notifyError } = useNotification(); + + const invoke = useCallback( + async (options: InvokeOptions): Promise> => { + setLoading(true); + setError(null); + + try { + if (!address) { + throw new Error('Connect your wallet before invoking a Soroban contract.'); + } + + assertValidContractId(contractId); + + const rpcUrl = getRpcUrl(options.rpcUrl); + const rpcServer = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const account = await rpcServer.getAccount(address); + const contract = new Contract(contractId); + + const transaction = new TransactionBuilder(account, { + fee: options.fee ?? BASE_FEE, + networkPassphrase: getNetworkPassphrase(options.networkPassphrase), + }) + .addOperation(contract.call(options.method, ...(options.args ?? []).map(toScVal))) + .setTimeout(options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) + .build(); + + const simulation = await simulateTransaction({ + envelopeXdr: transaction.toXDR(), + horizonUrl: import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined, + }); + + if (!simulation.success) { + throw new Error(simulation.description || 'Simulation failed'); + } + + const preparedTx = await rpcServer.prepareTransaction(transaction); + const signedXdr = await sign(preparedTx.toXDR()); + const signedTx = TransactionBuilder.fromXDR( + signedXdr, + getNetworkPassphrase(options.networkPassphrase) + ); + const sendResponse = await rpcServer.sendTransaction(signedTx); + + if (sendResponse.status === 'ERROR') { + throw new Error('Soroban contract submission failed.'); + } + + let txResponse: rpc.Api.GetTransactionResponse | null = null; + for (let attempt = 0; attempt < DEFAULT_MAX_POLL_ATTEMPTS; attempt += 1) { + const current = await rpcServer.getTransaction(sendResponse.hash); + if (current.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) { + txResponse = current; + break; + } + await sleep(DEFAULT_POLL_INTERVAL_MS); + } + + if (!txResponse) { + throw new Error('Transaction submission timed out before confirmation.'); + } + + if (txResponse.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`Transaction failed with status: ${txResponse.status}`); + } + + const raw = getResultValue(txResponse); + const typedValue = parseTypedResult(raw, options.parseResult); + const nextResult: SorobanInvokeResult = { + txHash: sendResponse.hash, + value: typedValue, + raw, + }; + + setResult(nextResult); + return nextResult; + } catch (invokeError) { + const message = + invokeError instanceof Error ? invokeError.message : 'Contract invocation failed'; + setError(message); + notifyError(`Contract invocation failed: ${options.method}`, message); + throw invokeError; + } finally { + setLoading(false); + } + }, + [address, contractId, notifyError, sign] + ); + + return { + invoke, + loading, + error, + result, + }; +} diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 655c5f17..b2bbb6a1 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -6,8 +6,11 @@ import { Search, ChevronLeft, ChevronRight, + Code2, } from 'lucide-react'; import { useNotification } from '../hooks/useNotification'; +import { useWallet } from '../hooks/useWallet'; +import ContractUpgradeTab from '../components/ContractUpgradeTab'; /** Centralized API base so URL changes happen in one place. */ const API_BASE = '/api/v1'; @@ -50,7 +53,7 @@ interface LogsApiResponse { total: number; } -type ActiveTab = 'account' | 'global' | 'status' | 'logs'; +type ActiveTab = 'account' | 'global' | 'status' | 'logs' | 'contracts'; // --------------------------------------------------------------------------- // Style constants – defined once to avoid repetition @@ -67,10 +70,12 @@ const TAB_LABELS: Record = { global: 'Global Asset Control', status: 'Status Check', logs: 'Audit Logs', + contracts: 'Contract Upgrades', }; export default function AdminPanel() { const { notifySuccess, notifyError } = useNotification(); + const { address: adminAddress } = useWallet(); const [activeTab, setActiveTab] = useState('account'); @@ -250,6 +255,7 @@ export default function AdminPanel() {
{(Object.keys(TAB_LABELS) as ActiveTab[]).map((tab) => ( ))} @@ -510,6 +516,9 @@ export default function AdminPanel() {
)} + {/* ── Contract Upgrades ────────────────────────────────────── */} + {activeTab === 'contracts' && } + {/* ── Audit Logs ───────────────────────────────────────────── */} {activeTab === 'logs' && (
diff --git a/frontend/src/pages/EmployeePortal.tsx b/frontend/src/pages/EmployeePortal.tsx index bb6f1a34..aac20e8f 100644 --- a/frontend/src/pages/EmployeePortal.tsx +++ b/frontend/src/pages/EmployeePortal.tsx @@ -22,7 +22,6 @@ import { } from '../services/currencyConversion'; import styles from './EmployeePortal.module.css'; import { useWallet } from '../hooks/useWallet'; -import { fetchPendingClaims, type PendingClaimRecord } from '../services/claimsApi'; /* ── Helper: status badge ────────── */ function StatusBadge({ status }: { status: EmployeeTransaction['status'] }) { @@ -65,8 +64,6 @@ function LoadingSkeleton() { /* ── Main Page Component ─────────── */ const EmployeePortal: React.FC = () => { const { address } = useWallet(); - const [pendingClaims, setPendingClaims] = React.useState([]); - const [pendingClaimsError, setPendingClaimsError] = React.useState(null); const { transactions, balance, @@ -94,34 +91,6 @@ const EmployeePortal: React.FC = () => { const pendingCount = transactions.filter((t) => t.status === 'pending').length; const lastPayment = transactions.find((t) => t.status === 'completed'); - React.useEffect(() => { - let cancelled = false; - - async function loadPendingClaims() { - if (!address) { - setPendingClaims([]); - setPendingClaimsError(null); - return; - } - - try { - setPendingClaimsError(null); - const claims = await fetchPendingClaims(address); - if (!cancelled) setPendingClaims(claims); - } catch (e) { - if (!cancelled) { - setPendingClaims([]); - setPendingClaimsError(e instanceof Error ? e.message : 'Failed to load pending claims'); - } - } - } - - void loadPendingClaims(); - return () => { - cancelled = true; - }; - }, [address]); - return (
{/* ── Page Header ─────────────── */} @@ -266,66 +235,6 @@ const EmployeePortal: React.FC = () => {
)} - {/* ── Pending Claims ───────────── */} - {(pendingClaimsError || pendingClaims.length > 0) && ( -
-
-

Pending Claims

- -
- - {pendingClaimsError ? ( -
{pendingClaimsError}
- ) : ( -
-
- If you have a pending claim, add the ORGUSD trustline in your wallet and then claim - the balance. -
- {pendingClaims.map((c) => ( -
-
-
- {c.amount} {c.asset_code} -
-
- Created {new Date(c.created_at).toLocaleString()} -
-
-
- Balance ID: {c.stellar_balance_id || '—'} -
-
- ))} -
- )} -
- )} - {/* ── Transactions Table ────────── */}
diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 6d9987c0..802f3c9c 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { Card, Heading, Text, Button, Input, Select } from '@stellar/design-system'; import { SchedulingWizard } from '../components/SchedulingWizard'; import { CountdownTimer } from '../components/CountdownTimer'; +import { BulkPaymentStatusTracker } from '../components/BulkPaymentStatusTracker'; interface PayrollFormState { employeeName: string; @@ -500,6 +501,10 @@ export default function PayrollScheduler() { )}
+ +
+ +
); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dbbcad0c..5921a401 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,206 +1,34 @@ import { useTranslation } from 'react-i18next'; -import { useState, useRef } from 'react'; export default function Settings() { const { t, i18n } = useTranslation(); - // Orginization State - const [orgName, setOrgName] = useState('Acme Corp'); - const [contactEmail, setContactEmail] = useState('admin@acmecorp.com'); - const [stablecoin, setStablecoin] = useState('USDC'); - const [logoPreview, setLogoPreview] = useState(null); - - // UI State - const [isSaving, setIsSaving] = useState(false); - const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); - - const fileInputRef = useRef(null); - const handleChangeLanguage = (event: React.ChangeEvent) => { void i18n.changeLanguage(event.target.value); }; - const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - setLogoPreview(reader.result as string); - }; - reader.readAsDataURL(file); - } - }; - - const handleSave = async () => { - setIsSaving(true); - setSaveStatus('idle'); - // Simulate API request - await new Promise((resolve) => setTimeout(resolve, 800)); - setIsSaving(false); - setSaveStatus('success'); - - // Clear success message after 3s - setTimeout(() => setSaveStatus('idle'), 3000); - }; - - const handleCancel = () => { - // Reset to mock original values - setOrgName('Acme Corp'); - setContactEmail('admin@acmecorp.com'); - setStablecoin('USDC'); - setLogoPreview(null); - setSaveStatus('idle'); - }; - return (
-

- {t('settings.title') || 'Settings'} -

+

{t('settings.title')}

-
- {/* Organization Settings Section */} -
-

Organization Profile

- -
- {/* Logo Upload */} -
- -
-
fileInputRef.current?.click()} - > - {logoPreview ? ( - Org Logo - ) : ( - 🏢 - )} -
-
- - -

- Recommended size: 256x256px. Max file size: 2MB. -

-
-
-
- - {/* Form Fields */} -
-
- - setOrgName(e.target.value)} - className="w-full bg-black/20 border border-hi rounded-xl p-3 text-text outline-none focus:border-accent/50 focus:bg-accent/5 transition-all" - placeholder="e.g. Acme Corp" - /> -
- -
- - setContactEmail(e.target.value)} - className="w-full bg-black/20 border border-hi rounded-xl p-3 text-text outline-none focus:border-accent/50 focus:bg-accent/5 transition-all" - placeholder="admin@example.com" - /> -
- -
- -

- Default token used for payroll distributions. -

- -
-
- - {/* Actions */} -
- {saveStatus === 'success' && ( - - Settings saved successfully! - - )} - - - -
-
-
- - {/* Existing Localization Section */} -
-
- -

- {t('settings.languageDescription') || - 'Choose your preferred user interface language.'} -

- -
+
+
+ +

{t('settings.languageDescription')}

+
diff --git a/frontend/src/services/claimsApi.ts b/frontend/src/services/claimsApi.ts deleted file mode 100644 index 6f05f588..00000000 --- a/frontend/src/services/claimsApi.ts +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'axios'; - -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1'; - -export interface PendingClaimRecord { - id: string; - employee_id: number | null; - amount: string; - asset_code: string; - asset_issuer: string; - stellar_balance_id: string | null; - create_tx_hash: string | null; - created_at: string; - status: string; -} - -export const fetchPendingClaims = async (walletAddress: string): Promise => { - const { data } = await axios.get<{ success: boolean; data: PendingClaimRecord[] }>( - `${API_BASE_URL}/claims/pending`, - { - params: { walletAddress }, - } - ); - - return data.data; -}; diff --git a/frontend/src/services/contractUpgrade.ts b/frontend/src/services/contractUpgrade.ts new file mode 100644 index 00000000..fed17b11 --- /dev/null +++ b/frontend/src/services/contractUpgrade.ts @@ -0,0 +1,220 @@ +/** + * Contract Upgrade API Service + * + * Thin fetch wrapper over the /api/v1/contracts endpoints. + * All functions throw on non-2xx responses so callers only need + * to handle the happy-path; errors bubble up to try/catch in hooks. + * + * No in-module state — every call is a pure async function. + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const API_BASE = '/api/v1/contracts'; + +// --------------------------------------------------------------------------- +// Types (mirror backend service types) +// --------------------------------------------------------------------------- + +export interface ContractRecord { + id: number; + name: string; + description: string | null; + network: string; + contract_id: string; + current_wasm_hash: string; + version: string; + last_upgraded_at: string | null; + last_upgraded_by: string | null; + created_at: string; +} + +export interface MigrationStep { + id: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + message: string | null; +} + +export type UpgradeLogStatus = + | 'pending' + | 'simulated' + | 'confirmed' + | 'executing' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface UpgradeSimulationResult { + success: boolean; + estimatedFee: string; + estimatedFeeXlm: string; + cpuInstructions: string; + memoryBytes: string; + latestLedger: number; + transactionData: string | null; + warnings: string[]; + error: string | null; +} + +export interface UpgradeLog { + id: number; + registry_id: number; + previous_wasm_hash: string; + new_wasm_hash: string; + status: UpgradeLogStatus; + simulation_result: UpgradeSimulationResult | null; + tx_hash: string | null; + migration_steps: MigrationStep[]; + initiated_by: string; + notes: string | null; + error_message: string | null; + created_at: string; + completed_at: string | null; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Executes a fetch and throws a typed Error if the response is not 2xx. + * Extracts the backend error message when available. + * + * Time/space: O(1) per call. + */ +async function apiFetch(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + + const body = (await response.json()) as Record; + + if (!response.ok) { + const message = + (body.error as string | undefined) ?? + (body.message as string | undefined) ?? + `Request failed with status ${response.status}`; + throw new Error(message); + } + + return body as T; +} + +// --------------------------------------------------------------------------- +// Public API functions +// --------------------------------------------------------------------------- + +/** + * List all registered Soroban contracts. + * + * GET /api/v1/contracts + */ +export async function fetchContracts(): Promise { + const data = await apiFetch<{ success: boolean; data: ContractRecord[] }>(API_BASE); + return data.data; +} + +/** + * Fetch a single contract by its registry ID. + * + * GET /api/v1/contracts/:registryId + */ +export async function fetchContract(registryId: number): Promise { + const data = await apiFetch<{ success: boolean; data: ContractRecord }>( + `${API_BASE}/${registryId}` + ); + return data.data; +} + +/** + * Validate a candidate WASM hash against format rules and on-chain existence. + * + * POST /api/v1/contracts/:registryId/validate-hash + * Returns { valid, reason? } + */ +export async function validateWasmHash( + registryId: number, + newWasmHash: string +): Promise<{ valid: boolean; reason?: string }> { + const data = await apiFetch<{ success: boolean; valid: boolean; reason?: string }>( + `${API_BASE}/${registryId}/validate-hash`, + { + method: 'POST', + body: JSON.stringify({ newWasmHash }), + } + ); + return { valid: data.valid, reason: data.reason }; +} + +/** + * Simulate the upgrade transaction and create an upgrade log row. + * + * POST /api/v1/contracts/:registryId/simulate-upgrade + * Returns upgradeLogId and simulation cost details. + */ +export async function simulateUpgrade( + registryId: number, + newWasmHash: string, + initiatedBy: string, + notes?: string +): Promise<{ upgradeLogId: number; simulation: UpgradeSimulationResult; message: string }> { + return apiFetch(`${API_BASE}/${registryId}/simulate-upgrade`, { + method: 'POST', + body: JSON.stringify({ newWasmHash, initiatedBy, notes }), + }); +} + +/** + * Execute a previously simulated upgrade on-chain. + * + * POST /api/v1/contracts/upgrade-logs/:logId/execute + * Body: { adminSecret } + */ +export async function executeUpgrade( + upgradeLogId: number, + adminSecret: string +): Promise<{ upgradeLogId: number; txHash: string; status: string; message: string }> { + return apiFetch(`${API_BASE}/upgrade-logs/${upgradeLogId}/execute`, { + method: 'POST', + body: JSON.stringify({ adminSecret }), + }); +} + +/** + * Poll the current migration status of an upgrade log. + * + * GET /api/v1/contracts/upgrade-logs/:logId/status + */ +export async function fetchUpgradeStatus(upgradeLogId: number): Promise { + const data = await apiFetch<{ success: boolean; data: UpgradeLog }>( + `${API_BASE}/upgrade-logs/${upgradeLogId}/status` + ); + return data.data; +} + +/** + * Cancel a pending or simulated upgrade. + * + * POST /api/v1/contracts/upgrade-logs/:logId/cancel + */ +export async function cancelUpgrade(upgradeLogId: number): Promise { + await apiFetch(`${API_BASE}/upgrade-logs/${upgradeLogId}/cancel`, { method: 'POST' }); +} + +/** + * Fetch paginated upgrade history for a contract. + * + * GET /api/v1/contracts/:registryId/upgrade-logs?page=1&limit=20 + */ +export async function fetchUpgradeLogs( + registryId: number, + page = 1, + limit = 20 +): Promise<{ data: UpgradeLog[]; total: number; page: number; limit: number }> { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + return apiFetch(`${API_BASE}/${registryId}/upgrade-logs?${params}`); +} diff --git a/frontend/src/services/contracts.example.tsx b/frontend/src/services/contracts.example.tsx new file mode 100644 index 00000000..24a771dc --- /dev/null +++ b/frontend/src/services/contracts.example.tsx @@ -0,0 +1,124 @@ +/** + * Contract Service Usage Examples + * + * This file demonstrates how to use the contract service to fetch + * contract addresses dynamically instead of hardcoding them. + */ + +import { useEffect, useState } from 'react'; +import { contractService } from './contracts'; +import { ContractType, NetworkType, ContractEntry } from './contracts.types'; + +/** + * Example 1: Get a specific contract ID in a component + */ +export function PaymentComponent() { + const [contractId, setContractId] = useState(null); + + useEffect(() => { + // Get the bulk_payment contract for testnet + const id = contractService.getContractId('bulk_payment', 'testnet'); + setContractId(id); + }, []); + + if (!contractId) { + return
Loading contract information...
; + } + + return ( +
+

Bulk Payment Contract

+

Contract ID: {contractId}

+
+ ); +} + +/** + * Example 2: Get all contracts and display them + */ +export function ContractListComponent() { + const [contracts, setContracts] = useState([]); + + useEffect(() => { + const registry = contractService.getAllContracts(); + if (registry) { + setContracts(registry.contracts); + } + }, []); + + return ( +
+

All Contracts

+
    + {contracts.map((contract) => ( +
  • + {contract.contractType} ({contract.network}): {contract.contractId} +
  • + ))} +
+
+ ); +} + +/** + * Example 3: Manually refresh the contract registry + */ +export function RefreshButton() { + const [loading, setLoading] = useState(false); + + const handleRefresh = () => { + setLoading(true); + contractService + .refreshRegistry() + .then(() => { + alert('Contract registry refreshed successfully!'); + }) + .catch(() => { + alert('Failed to refresh contract registry'); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + + ); +} + +/** + * Example 4: Dynamic contract selection + */ +export function DynamicContractSelector() { + const [contractType, setContractType] = useState('bulk_payment'); + const [network, setNetwork] = useState('testnet'); + const [contractId, setContractId] = useState(null); + + useEffect(() => { + const id = contractService.getContractId(contractType, network); + setContractId(id); + }, [contractType, network]); + + return ( +
+ + + + +

Contract ID: {contractId || 'Not found'}

+
+ ); +} diff --git a/frontend/src/services/contracts.ts b/frontend/src/services/contracts.ts new file mode 100644 index 00000000..bf5289b8 --- /dev/null +++ b/frontend/src/services/contracts.ts @@ -0,0 +1,138 @@ +/** + * Contract Service + * Fetches and caches contract registry data from the backend API + * + * Migration Guide: + * This service replaces hardcoded contract addresses with dynamic fetching from the backend. + * To migrate from hardcoded addresses: + * 1. Initialize the service: await contractService.initialize() + * 2. Get contract IDs: contractService.getContractId('bulk_payment', 'testnet') + * 3. The service handles caching and retry logic automatically + */ + +import axios, { AxiosError } from 'axios'; +import { ContractRegistry, ContractType, NetworkType } from './contracts.types'; + +class ContractService { + private cache: ContractRegistry | null = null; + private lastFetch: number | null = null; + private readonly CACHE_TTL = 3600000; // 1 hour in milliseconds + private readonly API_BASE_URL = + (import.meta.env.VITE_API_BASE_URL as string | undefined) || 'http://localhost:3000'; + private readonly MAX_RETRIES = 3; + + /** + * Initialize the service by fetching the contract registry + */ + async initialize(): Promise { + await this.fetchRegistry(); + } + + /** + * Fetch contract registry from the backend API with retry logic + */ + async fetchRegistry(): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { + try { + const response = await axios.get(`${this.API_BASE_URL}/api/contracts`); + + // Update cache + this.cache = response.data; + this.lastFetch = Date.now(); + + console.info(`Contract registry fetched successfully (${response.data.count} contracts)`); + return response.data; + } catch (error) { + lastError = error as Error; + + if (attempt === this.MAX_RETRIES) { + const errorMessage = + error instanceof AxiosError + ? `HTTP ${error.response?.status}: ${error.message}` + : (error as Error).message; + + console.error( + `Failed to fetch contract registry after ${this.MAX_RETRIES} attempts:`, + errorMessage + ); + throw new Error( + `Failed to fetch contracts after ${this.MAX_RETRIES} attempts: ${errorMessage}` + ); + } + + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, attempt - 1) * 1000; + console.warn(`Fetch attempt ${attempt} failed, retrying in ${delay}ms...`); + await this.sleep(delay); + } + } + + throw lastError || new Error('Failed to fetch contract registry'); + } + + /** + * Check if the cache is still valid + */ + isCacheValid(): boolean { + if (!this.cache || !this.lastFetch) { + return false; + } + + const age = Date.now() - this.lastFetch; + return age < this.CACHE_TTL; + } + + /** + * Get a contract ID by type and network + * Auto-refreshes cache if expired + */ + getContractId(contractType: ContractType, network: NetworkType): string | null { + // Auto-refresh if cache is stale + if (!this.isCacheValid()) { + console.info('Cache expired, refreshing...'); + // Fire and forget - don't block on refresh + this.fetchRegistry().catch((err) => { + console.error('Failed to refresh cache:', err); + }); + } + + if (!this.cache) { + console.warn('Contract registry not initialized. Call initialize() first.'); + return null; + } + + const contract = this.cache.contracts.find( + (c) => c.contractType === contractType && c.network === network + ); + + return contract?.contractId || null; + } + + /** + * Manually refresh the contract registry + */ + async refreshRegistry(): Promise { + this.cache = null; + this.lastFetch = null; + await this.fetchRegistry(); + } + + /** + * Get all contracts from cache + */ + getAllContracts(): ContractRegistry | null { + return this.cache; + } + + /** + * Sleep utility for retry delays + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// Export singleton instance +export const contractService = new ContractService(); diff --git a/frontend/src/services/contracts.types.ts b/frontend/src/services/contracts.types.ts new file mode 100644 index 00000000..aae5b889 --- /dev/null +++ b/frontend/src/services/contracts.types.ts @@ -0,0 +1,26 @@ +/** + * Contract Registry Type Definitions + * Type definitions for the Contract Address Registry API + */ + +export type NetworkType = 'testnet' | 'mainnet'; + +export type ContractType = + | 'bulk_payment' + | 'vesting_escrow' + | 'revenue_split' + | 'cross_asset_payment'; + +export interface ContractEntry { + contractId: string; + network: NetworkType; + contractType: ContractType; + version: string; + deployedAt: number; +} + +export interface ContractRegistry { + contracts: ContractEntry[]; + timestamp: string; + count: number; +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo new file mode 100644 index 00000000..30bd2000 --- /dev/null +++ b/frontend/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/applayout.tsx","./src/components/appnav.tsx","./src/components/autosaveindicator.tsx","./src/components/avatar.tsx","./src/components/avatarupload.tsx","./src/components/csvuploader.tsx","./src/components/connectaccount.tsx","./src/components/countdowntimer.tsx","./src/components/employeelist.tsx","./src/components/errorboundary.tsx","./src/components/errorfallback.tsx","./src/components/feeestimationpanel.tsx","./src/components/onboardingtour.tsx","./src/components/schedulingwizard.tsx","./src/components/themetoggle.tsx","./src/components/transactionsimulationpanel.tsx","./src/components/walletextensionbanner.tsx","./src/components/walletqrcode.tsx","./src/hooks/useautosave.ts","./src/hooks/useemployeeportal.ts","./src/hooks/usefeeestimation.ts","./src/hooks/usenotification.ts","./src/hooks/usesocket.ts","./src/hooks/usetheme.ts","./src/hooks/usetransactionsimulation.ts","./src/hooks/usewallet.ts","./src/hooks/usewalletsigning.ts","./src/pages/adminpanel.tsx","./src/pages/authcallback.tsx","./src/pages/crossassetpayment.tsx","./src/pages/customreportbuilder.tsx","./src/pages/debugger.tsx","./src/pages/employeeentry.tsx","./src/pages/employeeportal.tsx","./src/pages/feeestimation.tsx","./src/pages/helpcenter.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/payrollscheduler.tsx","./src/pages/settings.tsx","./src/pages/transactionhistory.tsx","./src/providers/notificationprovider.tsx","./src/providers/socketprovider.tsx","./src/providers/themeprovider.tsx","./src/providers/walletprovider.tsx","./src/services/anchor.ts","./src/services/auditapi.ts","./src/services/currencyconversion.ts","./src/services/feeestimation.ts","./src/services/pathfinding.ts","./src/services/stellar.ts","./src/services/transactionsimulation.ts","./src/utils/imageoptimization.ts"],"version":"5.9.3"} \ No newline at end of file From 2008c903a9b39eb60de5b8d346ee3d81ddda8ef5 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Sun, 8 Mar 2026 13:40:30 +0100 Subject: [PATCH 4/4] fix(frontend): resolve TypeScript build failure in WalletExtensionBanner --- frontend/src/components/WalletExtensionBanner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/WalletExtensionBanner.tsx b/frontend/src/components/WalletExtensionBanner.tsx index 78b4611f..a1b0058f 100644 --- a/frontend/src/components/WalletExtensionBanner.tsx +++ b/frontend/src/components/WalletExtensionBanner.tsx @@ -4,10 +4,10 @@ import { useWallet } from '../hooks/useWallet'; import { useTranslation } from 'react-i18next'; export const WalletExtensionBanner: React.FC = () => { - const { isExtensionAvailable } = useWallet(); + const { walletExtensionAvailable } = useWallet(); const { t } = useTranslation(); - if (isExtensionAvailable) return null; + if (walletExtensionAvailable) return null; return (