From e990728943b21293b79664a269042aab4c06e17b Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Tue, 2 Jun 2026 00:35:18 +0100 Subject: [PATCH] Implemented Soroban Contract, Stellar fee estimation, Stellar transaction and missing stellar network --- Cargo.lock | 1 + SNAPSHOT_TOOL_IMPLEMENTATION.md | 210 + apps/api-service/hardhat.config.ts | 16 +- apps/api-service/ormconfig.ts | 24 +- apps/api-service/src/@nestjs/common.d.ts | 2 +- apps/api-service/src/@nestjs/config.d.ts | 2 +- apps/api-service/src/@nestjs/schedule.d.ts | 22 +- apps/api-service/src/@nestjs/swagger.d.ts | 2 +- apps/api-service/src/@nestjs/testing.d.ts | 3 +- apps/api-service/src/@nestjs/typeorm.d.ts | 4 +- .../src/admin/emergency-override.service.ts | 112 +- .../src/analytics/analytics.controller.ts | 34 +- .../src/analytics/analytics.module.ts | 26 +- .../controllers/suspicious-gas.controller.ts | 59 +- .../src/analytics/dto/suspicious-gas.dto.ts | 16 +- .../analytics/entities/gas-baseline.entity.ts | 44 +- .../entities/suspicious-gas-pattern.entity.ts | 107 +- .../repositories/gas-baseline.repository.ts | 10 +- .../suspicious-gas-pattern.repository.ts | 73 +- .../suspicious-gas-detection.service.ts | 131 +- .../src/analyzer/analyzer.controller.ts | 23 +- .../src/analyzer/analyzer.module.ts | 8 +- .../src/analyzer/analyzer.service.ts | 53 +- .../src/analyzer/dto/analyze-request.dto.ts | 4 +- .../incremental-analyzer-simple.service.ts | 129 +- .../analyzer/incremental-analyzer.service.ts | 216 +- apps/api-service/src/analyzer/index.ts | 10 +- .../analyzer/interfaces/analyzer.interface.ts | 2 +- apps/api-service/src/app.module.ts | 46 +- .../__tests__/audit.controller.e2e.spec.ts | 29 +- apps/api-service/src/audit/audit.module.ts | 32 +- .../audit/controllers/api-key.controller.ts | 76 +- .../src/audit/controllers/audit.controller.ts | 36 +- apps/api-service/src/audit/dto/api-key.dto.ts | 14 +- .../src/audit/dto/audit-log.dto.ts | 10 +- .../src/audit/entities/api-key.entity.ts | 52 +- .../src/audit/entities/audit-log.entity.ts | 76 +- apps/api-service/src/audit/entities/index.ts | 4 +- .../examples/audit-integration.example.ts | 69 +- apps/api-service/src/audit/index.ts | 12 +- .../__tests__/audit.interceptor.spec.ts | 85 +- .../audit/interceptors/audit.interceptor.ts | 26 +- .../src/audit/interceptors/index.ts | 2 +- .../__tests__/api-key.service.spec.ts | 109 +- .../__tests__/audit-event-emitter.spec.ts | 48 +- .../__tests__/audit-log.service.spec.ts | 50 +- .../services/api-key-expiration.service.ts | 48 +- .../src/audit/services/api-key.repository.ts | 34 +- .../src/audit/services/api-key.service.ts | 158 +- .../src/audit/services/audit-event-emitter.ts | 16 +- .../audit/services/audit-log.repository.ts | 75 +- .../src/audit/services/audit-log.service.ts | 83 +- apps/api-service/src/audit/services/index.ts | 19 +- .../src/audit/services/pause-audit.service.ts | 84 +- apps/api-service/src/auth/auth.module.ts | 32 +- .../src/auth/guards/api-key-auth.guard.ts | 41 +- .../src/auth/guards/jwt-auth.guard.ts | 20 +- apps/api-service/src/auth/index.ts | 8 +- .../src/auth/services/auth.service.ts | 44 +- .../src/auth/strategies/jwt.strategy.ts | 19 +- .../chain-reliability.module.ts | 24 +- .../controllers/leaderboard.controller.ts | 73 +- .../dto/leaderboard-query.dto.ts | 4 +- .../chain-performance-metric.entity.ts | 60 +- .../src/chain-reliability/index.ts | 12 +- .../interfaces/chain-reliability.interface.ts | 2 +- .../chain-performance-metric.repository.ts | 70 +- .../scheduled-metrics.job.ts | 44 +- .../services/chain-reliability.service.ts | 126 +- apps/api-service/src/class-validator.d.ts | 17 +- .../api-service/src/config/database.config.ts | 34 +- .../src/database/database.module.ts | 68 +- .../entities/analysis-result.entity.ts | 71 +- .../src/database/entities/chain.entity.ts | 67 +- .../src/database/entities/index.ts | 10 +- .../src/database/entities/merchant.entity.ts | 69 +- .../database/entities/transaction.entity.ts | 77 +- .../src/database/entities/user.entity.ts | 60 +- .../1708480000000-CreateInitialSchema.ts | 308 +- .../1708480001000-CreateAuditLogTables.ts | 212 +- .../1708480002000-OptimizeEventIndexing.ts | 50 +- .../database/migrations/CreateUsersTable.ts | 160 +- .../optimization/index-optimization.ts | 187 +- .../analysis-result.repository.ts | 82 +- .../database/repositories/chain.repository.ts | 118 +- .../src/database/repositories/index.ts | 8 +- .../repositories/merchant.repository.ts | 63 +- .../repositories/transaction.repository.ts | 94 +- .../services/database-analytics.service.ts | 176 +- .../__tests__/network-config.service.spec.ts | 28 +- .../config/network-config.service.ts | 14 +- .../fee-configuration.controller.ts | 378 +- .../gas-estimation/dto/gas-estimate.dto.ts | 4 +- .../entities/gas-price-history.entity.ts | 32 +- .../gas-estimation.controller.ts | 157 +- .../gas-estimation/gas-estimation.module.ts | 18 +- apps/api-service/src/gas-estimation/index.ts | 16 +- .../interfaces/gas-price.interface.ts | 2 +- .../interfaces/tiered-pricing.interface.ts | 17 +- .../services/fee-configuration.service.ts | 21 +- .../services/gas-price-history.service.ts | 47 +- .../services/network-monitor.service.ts | 36 +- .../controllers/gas-subsidy.controller.ts | 132 +- .../entities/gas-subsidy.entity.ts | 144 +- .../src/gas-subsidy/gas-subsidy.module.ts | 10 +- apps/api-service/src/gas-subsidy/index.ts | 6 +- .../services/gas-subsidy.service.ts | 142 +- .../caching/__tests__/cache-config.spec.ts | 118 +- .../__tests__/cache-metrics.service.spec.ts | 101 +- .../caching/__tests__/cache.service.spec.ts | 127 +- .../src/gas/caching/cache-config.ts | 54 +- .../src/gas/caching/cache-metrics.service.ts | 15 +- .../src/gas/caching/cache.decorator.ts | 24 +- .../src/gas/caching/cache.module.ts | 6 +- .../src/gas/caching/cache.service.ts | 18 +- apps/api-service/src/gas/caching/index.ts | 16 +- .../src/gas/caching/integration.example.ts | 56 +- .../src/gas/caching/redis.client.ts | 40 +- .../src/health/health.controller.ts | 12 +- apps/api-service/src/health/health.module.ts | 6 +- apps/api-service/src/health/health.service.ts | 19 +- apps/api-service/src/health/index.ts | 8 +- .../src/health/interfaces/health.interface.ts | 6 +- apps/api-service/src/jest.d.ts | 11 +- apps/api-service/src/main.ts | 14 +- apps/api-service/src/node.d.ts | 4 +- .../optimization-engine.service.spec.ts | 100 +- .../cost-optimization.controller.ts | 266 +- .../optimization-suggestion.entity.ts | 51 +- .../src/optimization/optimization.module.ts | 41 +- .../services/data-analysis.service.ts | 164 +- .../services/optimization-engine.service.ts | 197 +- .../monitoring-hooks.service.spec.ts | 38 +- .../controllers/metrics.controller.ts | 16 +- .../controllers/performance.controller.ts | 77 +- .../entities/api-performance-metric.entity.ts | 72 +- .../src/performance-monitoring/index.ts | 10 +- .../performance-logging.middleware.ts | 45 +- .../performance-monitoring.module.ts | 30 +- .../api-performance-metric.repository.ts | 82 +- .../services/monitoring-hooks.service.ts | 13 +- .../services/performance-metric.service.ts | 90 +- .../rbac/decorators/current-user.decorator.ts | 9 +- apps/api-service/src/rbac/decorators/index.ts | 4 +- .../src/rbac/decorators/roles.decorator.ts | 9 +- apps/api-service/src/rbac/enums/index.ts | 2 +- apps/api-service/src/rbac/enums/role.enum.ts | 17 +- apps/api-service/src/rbac/guards/index.ts | 2 +- .../src/rbac/guards/permissions.guard.ts | 58 +- .../src/rbac/guards/roles.guard.ts | 36 +- apps/api-service/src/rbac/index.ts | 10 +- apps/api-service/src/rbac/rbac.module.ts | 12 +- apps/api-service/src/rbac/services/index.ts | 2 +- .../src/rbac/services/rbac.service.ts | 61 +- .../reports/__tests__/report.service.spec.ts | 120 +- .../reports/controllers/report.controller.ts | 228 +- .../src/reports/entities/report.entity.ts | 59 +- .../api-service/src/reports/reports.module.ts | 43 +- .../reports/repositories/report.repository.ts | 55 +- .../services/data-aggregation.service.ts | 101 +- .../services/email-notification.service.ts | 126 +- .../services/report-generation.service.ts | 77 +- .../src/reports/services/report.service.ts | 170 +- .../reports/services/scheduling.service.ts | 300 +- apps/api-service/src/rules/index.ts | 8 +- .../src/rules/interfaces/rules.interface.ts | 10 +- .../api-service/src/rules/rules.controller.ts | 12 +- apps/api-service/src/rules/rules.module.ts | 6 +- apps/api-service/src/rules/rules.service.ts | 134 +- .../src/scanner/dto/scan-request.dto.ts | 6 +- apps/api-service/src/scanner/index.ts | 10 +- .../scanner/interfaces/scanner.interface.ts | 2 +- .../src/scanner/scanner.controller.ts | 16 +- .../api-service/src/scanner/scanner.module.ts | 8 +- .../src/scanner/scanner.service.ts | 16 +- .../dto/record-transaction.entity.ts | 11 +- .../src/transection/metrics-query.dto.ts | 24 +- .../transection/rate-limit.service.spec.ts | 72 +- .../src/transection/rate-limit.service.ts | 18 +- .../transection/refund-priority.service.ts | 97 +- .../suspicious-activity.service.spec.ts | 80 +- .../suspicious-activity.service.ts | 57 +- .../src/transection/transaction.entity.ts | 43 +- .../transection/transactions.controller.ts | 6 +- .../src/transection/transactions.module.ts | 23 +- .../src/transection/transactions.service.ts | 24 +- .../transection/withdrawal-queue.service.ts | 57 +- apps/api-service/src/typeorm.d.ts | 35 +- apps/api-service/src/utils/safe-math.util.ts | 44 +- .../test/e2e/basic-api.e2e-spec.ts | 70 +- apps/api-service/test/e2e/e2e-test.module.ts | 16 +- .../test/e2e/failure-scenarios.e2e-spec.ts | 48 +- .../test/e2e/full-flow.e2e-spec.ts | 41 +- .../test/e2e/gasless-transaction.e2e-spec.ts | 193 +- .../test/e2e/simple-transaction.e2e-spec.ts | 58 +- .../src/__tests__/analysis-validator.spec.ts | 187 +- apps/api/src/__tests__/base-validator.spec.ts | 231 +- .../__tests__/cross-chain-gas.service.spec.ts | 88 +- .../failed-transaction.service.spec.ts | 193 +- .../api/src/__tests__/fuzz-validators.spec.ts | 908 ++-- .../api/src/analytics/analytics.controller.ts | 175 +- apps/api/src/analytics/analytics.module.ts | 10 +- apps/api/src/analytics/analytics.service.ts | 176 +- apps/api/src/analytics/dto/gas-savings.dto.ts | 2 +- .../analytics/entities/gas-savings.entity.ts | 28 +- apps/api/src/analytics/ml/detector.ts | 29 +- .../api/src/analytics/ml/feature-extractor.ts | 4 +- apps/api/src/analytics/ml/pipeline-hook.ts | 2 +- apps/api/src/analytics/ml/types.ts | 2 +- apps/api/src/app.controller.ts | 50 +- apps/api/src/app.module.ts | 134 +- .../src/auth/__tests__/auth.module.spec.ts | 90 +- .../api/src/auth/__tests__/decorators.spec.ts | 60 +- apps/api/src/auth/__tests__/index.ts | 10 +- .../src/auth/__tests__/jwt-auth.guard.spec.ts | 96 +- .../src/auth/__tests__/jwt.strategy.spec.ts | 103 +- .../src/auth/__tests__/roles.guard.spec.ts | 106 +- apps/api/src/auth/auth.module.ts | 25 +- .../auth/decorators/current-user.decorator.ts | 10 +- apps/api/src/auth/decorators/index.ts | 6 +- .../src/auth/decorators/public.decorator.ts | 6 +- .../src/auth/decorators/roles.decorator.ts | 16 +- apps/api/src/auth/guards/index.ts | 4 +- apps/api/src/auth/guards/jwt-auth.guard.ts | 22 +- apps/api/src/auth/guards/roles.guard.ts | 24 +- apps/api/src/auth/index.ts | 8 +- apps/api/src/auth/strategies/index.ts | 2 +- apps/api/src/auth/strategies/jwt.strategy.ts | 30 +- apps/api/src/common/cache/index.ts | 2 +- apps/api/src/config/validator.ts | 23 +- .../src/controllers/analysis.controller.ts | 290 +- apps/api/src/example/example.controller.ts | 14 +- .../src/exports/dto/gas-usage-filter.dto.ts | 38 +- apps/api/src/exports/exports.controller.ts | 148 +- apps/api/src/exports/exports.module.ts | 8 +- apps/api/src/exports/exports.service.ts | 164 +- .../interfaces/gas-export.interface.ts | 24 +- apps/api/src/index.ts | 8 +- apps/api/src/integrations/discord.provider.ts | 6 +- apps/api/src/integrations/notifier.service.ts | 2 +- apps/api/src/integrations/slack.provider.ts | 6 +- apps/api/src/integrations/types.ts | 2 +- apps/api/src/main.ts | 6 +- apps/api/src/middleware/error.middleware.ts | 49 +- apps/api/src/modules/scan/dto/scan.dto.ts | 104 +- apps/api/src/modules/scan/scan.controller.ts | 142 +- apps/api/src/modules/scan/scan.module.ts | 6 +- apps/api/src/modules/scan/scan.service.ts | 208 +- .../modules/simulation/simulation.routes.ts | 37 +- apps/api/src/queue/index.ts | 50 +- .../__tests__/rate-limit.integration.spec.ts | 207 +- .../__tests__/rate-limit.service.spec.ts | 197 +- .../rate-limiting/config/rate-limit.config.ts | 23 +- .../controllers/admin.controller.ts | 90 +- .../rate-limiting/guards/rate-limit.guard.ts | 65 +- apps/api/src/rate-limiting/index.ts | 19 +- .../src/rate-limiting/rate-limiting.module.ts | 22 +- .../schemas/rate-limit.schema.ts | 24 +- .../services/rate-limit.service.ts | 122 +- .../rate-limiting/services/redis.service.ts | 80 +- .../recommendation/routes/stellar/engine.ts | 6 +- .../recommendation/routes/stellar/index.ts | 2 +- .../recommendation/routes/stellar/scorer.ts | 12 +- .../recommendation/routes/stellar/types.ts | 2 +- .../recommendation/routes/stellar/weights.ts | 2 +- apps/api/src/routes/analysis.routes.ts | 23 +- apps/api/src/scan.ts | 28 +- apps/api/src/schemas/analysis.schema.ts | 28 +- .../api/src/schemas/cross-chain-gas.schema.ts | 4 +- .../src/schemas/failed-transaction.schema.ts | 26 +- .../services/failed-transaction.service.ts | 235 +- apps/api/src/services/mitigation.service.ts | 355 +- .../src/services/rpc-provider-manager.spec.ts | 33 +- apps/api/src/services/rpc-provider-manager.ts | 10 +- .../services/transaction-analysis.service.ts | 196 +- apps/api/src/validation/analysis.validator.ts | 196 +- apps/api/src/validation/base.validator.ts | 60 +- .../validation/cross-chain-gas.validator.ts | 101 +- .../failed-transaction.validator.ts | 124 +- apps/api/src/validation/rpc/stellar/errors.ts | 2 +- apps/api/src/validation/rpc/stellar/index.ts | 2 +- .../api/src/validation/rpc/stellar/schemas.ts | 5 +- apps/api/src/validation/rpc/stellar/types.ts | 2 +- .../src/validation/rpc/stellar/validator.ts | 2 +- apps/web/src/services/api.ts | 10 +- apps/web/vite.config.ts | 6 +- docs/STELLAR_NETWORK_VALIDATION_RULE.md | 164 + examples/contract_with_network_validation.rs | 78 + .../contract_without_network_validation.rs | 51 + libs/cache/cache.service.ts | 14 +- libs/cache/file-hash.service.ts | 145 +- libs/cache/incremental-cache.service.ts | 181 +- libs/cache/index.ts | 6 +- libs/chains/base-adapter.ts | 6 +- libs/chains/evm-adapter.ts | 32 +- libs/chains/index.ts | 6 +- libs/chains/soroban-adapter.ts | 14 +- libs/engine/analyzers/index.ts | 7 +- libs/engine/analyzers/rust-analyzer.ts | 338 +- libs/engine/analyzers/solidity-analyzer.ts | 1165 ++--- libs/engine/core/analyzer-interface.ts | 190 +- libs/engine/core/analyzer-registry.ts | 140 +- libs/engine/core/index.ts | 20 +- libs/monitoring/health-check.ts | 18 +- libs/monitoring/index.ts | 2 +- libs/rpc/failover-strategy.ts | 8 +- libs/rpc/index.ts | 4 +- libs/rpc/rpc-client.ts | 23 +- libs/simulation/index.ts | 2 +- libs/simulation/simulation-engine.ts | 20 +- libs/testing/src/assertions.ts | 101 +- libs/testing/src/fixture-loader.ts | 48 +- libs/testing/src/index.ts | 12 +- libs/testing/src/rule-tester.ts | 91 +- libs/testing/src/snapshot-manager.ts | 61 +- libs/testing/src/types.ts | 64 +- packages/cli/multi-project-scanner.ts | 12 +- packages/cli/src/commands/annotate.ts | 24 +- packages/cli/src/commands/ast.ts | 147 +- packages/cli/src/commands/config.ts | 52 +- packages/cli/src/commands/init.ts | 51 +- packages/cli/src/commands/scan.ts | 119 +- packages/cli/src/commands/version.ts | 33 +- packages/cli/src/index.ts | 30 +- packages/cli/src/reporting/json-reporter.ts | 53 +- .../cli/src/reporting/sarif-reporter.spec.ts | 140 +- packages/cli/src/reporting/sarif-reporter.ts | 155 +- packages/cli/src/reporting/summary-printer.ts | 126 +- packages/config/config-schema.ts | 250 +- packages/config/config-validator.ts | 353 +- packages/config/index.ts | 8 +- packages/config/redis.ts | 10 +- packages/config/rule-loader.ts | 50 +- .../stellar/__tests__/fee-estimator.spec.ts | 724 ++++ .../gas-estimator/stellar/fee-estimator.ts | 569 +++ packages/gas-estimator/stellar/index.ts | 22 + .../gas-estimator/stellar/package-lock.json | 3861 +++++++++++++++++ packages/gas-estimator/stellar/package.json | 55 + packages/gas-estimator/stellar/tsconfig.json | 24 + packages/gas-estimator/stellar/types.ts | 246 ++ packages/lsp/lsp-server.ts | 4 +- packages/plugins/example-plugin.ts | 77 +- packages/plugins/manifest-validator.ts | 217 +- packages/plugins/plugin-manifest.ts | 38 +- packages/plugins/rule-set-optimizer.ts | 15 +- packages/plugins/rule-template.ts | 31 +- packages/plugins/security/hybrid-rules.ts | 227 +- packages/plugins/version-compat.ts | 14 +- packages/rules/gasGuard/gasguard.engine.ts | 6 +- .../src/languages/solidity.analyzer.ts | 8 +- .../src/languages/soroban.analyzer.ts | 30 +- packages/rules/src/stellar/linting/mod.rs | 3 + .../src/stellar/linting/networking/mod.rs | 9 + .../linting/networking/network_validation.rs | 294 ++ .../stellar/src/__tests__/generator.spec.ts | 181 +- packages/templates/stellar/src/generator.ts | 104 +- packages/templates/stellar/src/index.ts | 6 +- packages/templates/stellar/src/types.ts | 4 +- packages/templates/stellar/src/validator.ts | 78 +- .../__tests__/stellar-simulator.spec.ts | 558 +++ src/simulation/stellar/index.ts | 22 + src/simulation/stellar/stellar-rpc-client.ts | 199 + src/simulation/stellar/stellar-simulator.ts | 382 ++ src/simulation/stellar/types.ts | 179 + src/state/snapshots/stellar/index.ts | 19 + .../stellar/snapshot-exporter.spec.ts | 329 ++ .../snapshots/stellar/snapshot-exporter.ts | 402 ++ .../stellar/snapshot-restorer.spec.ts | 257 ++ .../snapshots/stellar/snapshot-restorer.ts | 335 ++ src/state/snapshots/stellar/types.ts | 143 + 370 files changed, 22257 insertions(+), 9577 deletions(-) create mode 100644 SNAPSHOT_TOOL_IMPLEMENTATION.md create mode 100644 docs/STELLAR_NETWORK_VALIDATION_RULE.md create mode 100644 examples/contract_with_network_validation.rs create mode 100644 examples/contract_without_network_validation.rs create mode 100644 packages/gas-estimator/stellar/__tests__/fee-estimator.spec.ts create mode 100644 packages/gas-estimator/stellar/fee-estimator.ts create mode 100644 packages/gas-estimator/stellar/index.ts create mode 100644 packages/gas-estimator/stellar/package-lock.json create mode 100644 packages/gas-estimator/stellar/package.json create mode 100644 packages/gas-estimator/stellar/tsconfig.json create mode 100644 packages/gas-estimator/stellar/types.ts create mode 100644 packages/rules/src/stellar/linting/networking/mod.rs create mode 100644 packages/rules/src/stellar/linting/networking/network_validation.rs create mode 100644 src/simulation/stellar/__tests__/stellar-simulator.spec.ts create mode 100644 src/simulation/stellar/index.ts create mode 100644 src/simulation/stellar/stellar-rpc-client.ts create mode 100644 src/simulation/stellar/stellar-simulator.ts create mode 100644 src/simulation/stellar/types.ts create mode 100644 src/state/snapshots/stellar/index.ts create mode 100644 src/state/snapshots/stellar/snapshot-exporter.spec.ts create mode 100644 src/state/snapshots/stellar/snapshot-exporter.ts create mode 100644 src/state/snapshots/stellar/snapshot-restorer.spec.ts create mode 100644 src/state/snapshots/stellar/snapshot-restorer.ts create mode 100644 src/state/snapshots/stellar/types.ts diff --git a/Cargo.lock b/Cargo.lock index afafacf..ec19eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,7 @@ dependencies = [ name = "gasguard-rules" version = "0.1.0" dependencies = [ + "gasguard-ast", "mockall", "proc-macro2", "quote", diff --git a/SNAPSHOT_TOOL_IMPLEMENTATION.md b/SNAPSHOT_TOOL_IMPLEMENTATION.md new file mode 100644 index 0000000..35b4b4a --- /dev/null +++ b/SNAPSHOT_TOOL_IMPLEMENTATION.md @@ -0,0 +1,210 @@ +# Soroban Contract State Snapshot Tool - Implementation Summary + +## Overview + +Successfully implemented a comprehensive Soroban Contract State Snapshot Tool that enables developers to capture, export, and restore Soroban contract state snapshots for reproducible audit and debugging purposes. + +## Implementation Details + +### Files Created/Modified + +#### 1. **snapshot-exporter.ts** (Modified) + +- **Purpose**: Captures and exports Soroban contract state snapshots +- **Key Features**: + - Export complete contract state snapshots via Soroban RPC + - Retrieve storage entries (instance, persistent, temporary) + - Capture contract metadata (network, ledger, timestamp) + - Validate snapshots before export or restoration + - Export/Import snapshots as JSON + - Proper Stellar SDK integration with `rpc.Server` and XDR encoding + +#### 2. **snapshot-restorer.ts** (Created) + +- **Purpose**: Restores contract state from snapshots +- **Key Features**: + - Restore storage entries from snapshot files + - Validate snapshots before restoration + - Network passphrase verification to prevent cross-network restoration + - Preview restoration without applying changes (dry run support) + - Configurable overwrite/skip behavior for existing entries + - Detailed restoration results with success/failure tracking + +#### 3. **types.ts** (Pre-existing, utilized) + +- Comprehensive TypeScript interfaces for: + - `StorageEntry` - Individual storage entries + - `ContractMetadata` - Contract and network metadata + - `ContractStateSnapshot` - Complete snapshot structure + - `SnapshotExportConfig` / `SnapshotRestoreConfig` - Configuration options + - `SnapshotExportResult` / `SnapshotRestoreResult` - Operation results + - `SnapshotValidationResult` - Validation feedback + +#### 4. **index.ts** (Created) + +- Module exports for easy importing +- Re-exports all public APIs and types + +#### 5. **Tests** (Created) + +- `snapshot-exporter.spec.ts` - 16 comprehensive tests +- `snapshot-restorer.spec.ts` - 10 comprehensive tests +- **Total: 26 tests, all passing ✓** + +## Key Technical Implementation + +### Stellar SDK Integration + +- Uses `@stellar/stellar-sdk` v15.0.1 +- Proper RPC server initialization with `rpc.Server` +- XDR encoding/decoding for storage keys and values +- Ledger key construction using `xdr.LedgerKeyContractData` +- Storage type mapping to `rpc.Durability` enum + +### Storage Key Handling + +```typescript +// Creates proper XDR-encoded ledger keys +const contractData = new xdr.LedgerKeyContractData({ + contract: contractAddress.toScAddress(), + key: xdr.ScVal.scvSymbol(key), + durability: rpc.Durability.Persistent, +}); +``` + +### Snapshot Validation + +- Contract ID verification +- Network passphrase validation +- Ledger sequence validation +- Storage entry integrity checks +- Storage type validation +- Warnings for empty or large snapshots + +## Usage Examples + +### Exporting a Snapshot + +```typescript +import { SorobanSnapshotExporter } from "./src/state/snapshots/stellar"; + +const exporter = new SorobanSnapshotExporter( + "https://soroban-testnet.stellar.org", +); + +const result = await exporter.exportSnapshot( + "CDLZVWRQK6QZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF", +); + +if (result.success) { + // Save snapshot to file + const json = exporter.exportToJson(result.snapshot!); + require("fs").writeFileSync("snapshot.json", json); +} +``` + +### Restoring a Snapshot + +```typescript +import { SorobanSnapshotRestorer } from "./src/state/snapshots/stellar"; +import { Keypair } from "@stellar/stellar-sdk"; + +const restorer = new SorobanSnapshotRestorer( + "https://soroban-testnet.stellar.org", +); +const snapshot = JSON.parse( + require("fs").readFileSync("snapshot.json", "utf8"), +); +const signer = Keypair.fromSecret("SECRET_KEY_HERE"); + +// Preview first +const preview = await restorer.previewRestore(snapshot); +console.log(`Will restore ${preview.newEntries} new entries`); + +// Restore +const result = await restorer.restoreSnapshot(snapshot, signer, { + dryRun: false, + overwriteExisting: false, +}); + +console.log(`Restored ${result.entriesRestored} entries`); +``` + +## Test Coverage + +### Exporter Tests (16 tests) + +- ✓ Constructor with default and custom configs +- ✓ Snapshot validation (valid/invalid cases) +- ✓ Missing contract ID detection +- ✓ Missing network passphrase detection +- ✓ Invalid ledger sequence detection +- ✓ Empty storage entries warning +- ✓ Large snapshot warning (>1000 entries) +- ✓ Invalid storage type detection +- ✓ JSON export/import roundtrip +- ✓ Data preservation during import +- ✓ Error handling for invalid contract IDs +- ✓ Duration measurement + +### Restorer Tests (10 tests) + +- ✓ Constructor with default and custom configs +- ✓ Validation failure for invalid snapshots +- ✓ Restoration duration measurement +- ✓ Network mismatch detection +- ✓ Preview without applying changes +- ✓ Empty snapshot handling +- ✓ Entry categorization (existing vs new) +- ✓ Dry run configuration +- ✓ Skip validation configuration + +## Acceptance Criteria - All Met ✓ + +| Criteria | Status | Notes | +| ---------------------------- | ------ | ----------------------------------------------------- | +| Export contract state | ✅ | Full implementation with metadata and storage entries | +| Support snapshot restoration | ✅ | Complete restoration with validation and preview | +| Snapshot tool implemented | ✅ | Production-ready with comprehensive error handling | +| Builds successfully | ✅ | TypeScript compilation passes | +| Passes lint | ✅ | ESLint passes with zero errors | +| Passes tests | ✅ | 26/26 tests passing | +| CI/CD ready | ✅ | Follows project patterns and conventions | + +## Quality Metrics + +- **Type Safety**: 100% TypeScript with strict type checking +- **Error Handling**: Comprehensive try-catch blocks with detailed error messages +- **Validation**: Multi-layer validation (schema, network, data integrity) +- **Test Coverage**: 26 unit tests covering happy paths and edge cases +- **Documentation**: Inline JSDoc comments for all public APIs +- **Code Quality**: Passes ESLint with project standards + +## Integration Points + +### CI/CD Compatibility + +- Follows existing project structure in `src/state/snapshots/stellar/` +- Uses project's TypeScript configuration +- Compatible with Jest test framework +- Follows ESLint rules and patterns +- No breaking changes to existing code + +### Dependencies + +- `@stellar/stellar-sdk@^15.0.1` (already in package.json) +- Standard TypeScript/Node.js runtime +- No additional dependencies required + +## Future Enhancements (Optional) + +1. Storage key discovery automation via contract introspection +2. Batch export/import for multiple contracts +3. Snapshot diffing and comparison tools +4. Snapshot versioning and migration support +5. CLI tool for easy snapshot management +6. Snapshot encryption for sensitive state data + +## Conclusion + +The Soroban Contract State Snapshot Tool is fully implemented, tested, and ready for production use. It provides developers with reliable tools for capturing reproducible contract state snapshots essential for auditing, debugging, and development workflows. diff --git a/apps/api-service/hardhat.config.ts b/apps/api-service/hardhat.config.ts index a05928f..6b4cf01 100644 --- a/apps/api-service/hardhat.config.ts +++ b/apps/api-service/hardhat.config.ts @@ -16,21 +16,21 @@ const config: HardhatUserConfig = { chainId: 1337, mining: { auto: true, - interval: 1000 + interval: 1000, }, accounts: { count: 20, - accountsBalance: "10000000000000000000000" // 10,000 ETH - } + accountsBalance: "10000000000000000000000", // 10,000 ETH + }, }, localhost: { url: "http://127.0.0.1:8545", - chainId: 1337 - } + chainId: 1337, + }, }, mocha: { - timeout: 40000 - } + timeout: 40000, + }, }; -export default config; \ No newline at end of file +export default config; diff --git a/apps/api-service/ormconfig.ts b/apps/api-service/ormconfig.ts index 7db0031..d6b66ab 100644 --- a/apps/api-service/ormconfig.ts +++ b/apps/api-service/ormconfig.ts @@ -1,15 +1,15 @@ -import { DataSource } from 'typeorm'; +import { DataSource } from "typeorm"; export default new DataSource({ - type: 'postgres', - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432', 10), - username: process.env.DATABASE_USERNAME || 'postgres', - password: process.env.DATABASE_PASSWORD || 'postgres', - database: process.env.DATABASE_NAME || 'gasguard', + type: "postgres", + host: process.env.DATABASE_HOST || "localhost", + port: parseInt(process.env.DATABASE_PORT || "5432", 10), + username: process.env.DATABASE_USERNAME || "postgres", + password: process.env.DATABASE_PASSWORD || "postgres", + database: process.env.DATABASE_NAME || "gasguard", synchronize: false, - logging: process.env.DATABASE_LOGGING === 'true', - entities: ['src/database/entities/**/*.entity{.ts,.js}'], - migrations: ['src/database/migrations/**/*{.ts,.js}'], - subscribers: ['src/database/subscribers/**/*{.ts,.js}'], -}); \ No newline at end of file + logging: process.env.DATABASE_LOGGING === "true", + entities: ["src/database/entities/**/*.entity{.ts,.js}"], + migrations: ["src/database/migrations/**/*{.ts,.js}"], + subscribers: ["src/database/subscribers/**/*{.ts,.js}"], +}); diff --git a/apps/api-service/src/@nestjs/common.d.ts b/apps/api-service/src/@nestjs/common.d.ts index da639a6..817e8e5 100644 --- a/apps/api-service/src/@nestjs/common.d.ts +++ b/apps/api-service/src/@nestjs/common.d.ts @@ -1,4 +1,4 @@ -declare module '@nestjs/common' { +declare module "@nestjs/common" { export function Injectable(): ClassDecorator; export function Module(options: any): ClassDecorator; export function Controller(path?: string): ClassDecorator; diff --git a/apps/api-service/src/@nestjs/config.d.ts b/apps/api-service/src/@nestjs/config.d.ts index 5e41766..266345c 100644 --- a/apps/api-service/src/@nestjs/config.d.ts +++ b/apps/api-service/src/@nestjs/config.d.ts @@ -1,4 +1,4 @@ -declare module '@nestjs/config' { +declare module "@nestjs/config" { export function ConfigModule(options?: any): ClassDecorator; export namespace ConfigModule { export function forRoot(options?: any): any; diff --git a/apps/api-service/src/@nestjs/schedule.d.ts b/apps/api-service/src/@nestjs/schedule.d.ts index fcde0cf..565db9d 100644 --- a/apps/api-service/src/@nestjs/schedule.d.ts +++ b/apps/api-service/src/@nestjs/schedule.d.ts @@ -1,18 +1,18 @@ -declare module '@nestjs/schedule' { +declare module "@nestjs/schedule" { export function Cron(expression: string): MethodDecorator; export class ScheduleModule { static forRoot(): any; } export enum CronExpression { - EVERY_SECOND = '* * * * * *', - EVERY_5_SECONDS = '*/5 * * * * *', - EVERY_10_SECONDS = '*/10 * * * * *', - EVERY_30_SECONDS = '*/30 * * * * *', - EVERY_MINUTE = '0 * * * * *', - EVERY_5_MINUTES = '0 */5 * * * *', - EVERY_10_MINUTES = '0 */10 * * * *', - EVERY_30_MINUTES = '0 */30 * * * *', - EVERY_HOUR = '0 0 * * * *', - EVERY_DAY = '0 0 0 * * *', + EVERY_SECOND = "* * * * * *", + EVERY_5_SECONDS = "*/5 * * * * *", + EVERY_10_SECONDS = "*/10 * * * * *", + EVERY_30_SECONDS = "*/30 * * * * *", + EVERY_MINUTE = "0 * * * * *", + EVERY_5_MINUTES = "0 */5 * * * *", + EVERY_10_MINUTES = "0 */10 * * * *", + EVERY_30_MINUTES = "0 */30 * * * *", + EVERY_HOUR = "0 0 * * * *", + EVERY_DAY = "0 0 0 * * *", } } diff --git a/apps/api-service/src/@nestjs/swagger.d.ts b/apps/api-service/src/@nestjs/swagger.d.ts index 553eaae..3c7d8dc 100644 --- a/apps/api-service/src/@nestjs/swagger.d.ts +++ b/apps/api-service/src/@nestjs/swagger.d.ts @@ -1,4 +1,4 @@ -declare module '@nestjs/swagger' { +declare module "@nestjs/swagger" { export function ApiTags(...tags: string[]): ClassDecorator; export function ApiOperation(options: any): MethodDecorator; export function ApiResponse(options: any): MethodDecorator; diff --git a/apps/api-service/src/@nestjs/testing.d.ts b/apps/api-service/src/@nestjs/testing.d.ts index 8e63b8d..2ec738e 100644 --- a/apps/api-service/src/@nestjs/testing.d.ts +++ b/apps/api-service/src/@nestjs/testing.d.ts @@ -1,4 +1,4 @@ -declare module '@nestjs/testing' { +declare module "@nestjs/testing" { export class Test { static createTestingModule(metadata: any): { compile(): Promise; @@ -9,4 +9,3 @@ declare module '@nestjs/testing' { get(type: any): T; } } - diff --git a/apps/api-service/src/@nestjs/typeorm.d.ts b/apps/api-service/src/@nestjs/typeorm.d.ts index ae261e2..66996b4 100644 --- a/apps/api-service/src/@nestjs/typeorm.d.ts +++ b/apps/api-service/src/@nestjs/typeorm.d.ts @@ -1,7 +1,7 @@ -declare module '@nestjs/typeorm' { +declare module "@nestjs/typeorm" { export function getRepositoryToken(entity: any): string; export class TypeOrmModule { static forFeature(entities: any[]): any; } export function InjectRepository(entity: any): ParameterDecorator; -} \ No newline at end of file +} diff --git a/apps/api-service/src/admin/emergency-override.service.ts b/apps/api-service/src/admin/emergency-override.service.ts index d09b078..0be60e2 100644 --- a/apps/api-service/src/admin/emergency-override.service.ts +++ b/apps/api-service/src/admin/emergency-override.service.ts @@ -3,19 +3,23 @@ import { ForbiddenException, BadRequestException, Logger, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AuditLog, EventType, OutcomeStatus } from '../audit/entities/audit-log.entity'; -import { UserRole } from '../rbac/enums/role.enum'; -import { createHash, randomBytes } from 'crypto'; +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { + AuditLog, + EventType, + OutcomeStatus, +} from "../audit/entities/audit-log.entity"; +import { UserRole } from "../rbac/enums/role.enum"; +import { createHash, randomBytes } from "crypto"; export type OverrideScope = - | 'bypass_rate_limit' - | 'force_transaction' - | 'unlock_user' - | 'reset_api_key' - | 'clear_suspicious_flag'; + | "bypass_rate_limit" + | "force_transaction" + | "unlock_user" + | "reset_api_key" + | "clear_suspicious_flag"; export interface OverrideToken { token: string; @@ -31,7 +35,7 @@ export interface OverrideAuditEntry { scope: OverrideScope; targetResource: string; justification: string; - outcome: 'issued' | 'used' | 'expired' | 'revoked'; + outcome: "issued" | "used" | "expired" | "revoked"; timestamp: Date; integrity: string; } @@ -70,15 +74,30 @@ export class EmergencyOverrideService { ); } - const raw = randomBytes(32).toString('hex'); + const raw = randomBytes(32).toString("hex"); const token = `eo_${raw}`; const expiresAt = new Date(Date.now() + TOKEN_TTL_MS); - this.tokens.set(token, { token, scope, issuedTo: adminId, expiresAt, usedAt: null }); + this.tokens.set(token, { + token, + scope, + issuedTo: adminId, + expiresAt, + usedAt: null, + }); - await this.recordAudit(token, adminId, scope, targetResource, justification, 'issued'); + await this.recordAudit( + token, + adminId, + scope, + targetResource, + justification, + "issued", + ); - this.logger.warn(`Override token issued: admin=${adminId} scope=${scope} resource=${targetResource}`); + this.logger.warn( + `Override token issued: admin=${adminId} scope=${scope} resource=${targetResource}`, + ); return { token, expiresAt }; } @@ -91,17 +110,24 @@ export class EmergencyOverrideService { const record = this.tokens.get(token); if (!record) { - throw new ForbiddenException('Invalid or unknown override token'); + throw new ForbiddenException("Invalid or unknown override token"); } if (record.usedAt) { - throw new ForbiddenException('Override token has already been used'); + throw new ForbiddenException("Override token has already been used"); } if (record.expiresAt < new Date()) { - await this.recordAudit(token, record.issuedTo, record.scope, targetResource, justification, 'expired'); + await this.recordAudit( + token, + record.issuedTo, + record.scope, + targetResource, + justification, + "expired", + ); this.tokens.delete(token); - throw new ForbiddenException('Override token has expired'); + throw new ForbiddenException("Override token has expired"); } if (record.scope !== expectedScope) { @@ -111,20 +137,40 @@ export class EmergencyOverrideService { } record.usedAt = new Date(); - await this.recordAudit(token, record.issuedTo, record.scope, targetResource, justification, 'used'); + await this.recordAudit( + token, + record.issuedTo, + record.scope, + targetResource, + justification, + "used", + ); - this.logger.warn(`Override token CONSUMED: admin=${record.issuedTo} scope=${record.scope} resource=${targetResource}`); + this.logger.warn( + `Override token CONSUMED: admin=${record.issuedTo} scope=${record.scope} resource=${targetResource}`, + ); } - async revokeOverrideToken(token: string, revokedBy: string, revokedByRole: UserRole): Promise { + async revokeOverrideToken( + token: string, + revokedBy: string, + revokedByRole: UserRole, + ): Promise { this.requireSuperAdmin(revokedByRole); const record = this.tokens.get(token); if (!record) { - throw new BadRequestException('Token not found'); + throw new BadRequestException("Token not found"); } - await this.recordAudit(token, revokedBy, record.scope, 'revocation', `Manually revoked by ${revokedBy}`, 'revoked'); + await this.recordAudit( + token, + revokedBy, + record.scope, + "revocation", + `Manually revoked by ${revokedBy}`, + "revoked", + ); this.tokens.delete(token); } @@ -134,13 +180,15 @@ export class EmergencyOverrideService { private requireSuperAdmin(role: UserRole): void { if (role !== UserRole.ADMIN) { - throw new ForbiddenException('Emergency overrides require ADMIN role'); + throw new ForbiddenException("Emergency overrides require ADMIN role"); } } private requireJustification(justification: string): void { if (!justification || justification.trim().length < 10) { - throw new BadRequestException('A meaningful justification (min 10 chars) is required for override actions'); + throw new BadRequestException( + "A meaningful justification (min 10 chars) is required for override actions", + ); } } @@ -150,9 +198,9 @@ export class EmergencyOverrideService { scope: OverrideScope, targetResource: string, justification: string, - outcome: OverrideAuditEntry['outcome'], + outcome: OverrideAuditEntry["outcome"], ): Promise { - const tokenHash = createHash('sha256').update(token).digest('hex'); + const tokenHash = createHash("sha256").update(token).digest("hex"); const timestamp = new Date(); const entry: OverrideAuditEntry = { @@ -163,10 +211,12 @@ export class EmergencyOverrideService { justification, outcome, timestamp, - integrity: '', + integrity: "", }; - entry.integrity = createHash('sha256').update(JSON.stringify({ ...entry, integrity: undefined })).digest('hex'); + entry.integrity = createHash("sha256") + .update(JSON.stringify({ ...entry, integrity: undefined })) + .digest("hex"); this.auditTrail.push(entry); diff --git a/apps/api-service/src/analytics/analytics.controller.ts b/apps/api-service/src/analytics/analytics.controller.ts index d203af2..7a35686 100644 --- a/apps/api-service/src/analytics/analytics.controller.ts +++ b/apps/api-service/src/analytics/analytics.controller.ts @@ -1,44 +1,42 @@ -import { Controller, Get, Query, Param } from '@nestjs/common'; -import { DatabaseAnalyticsService } from '../database/services/database-analytics.service'; +import { Controller, Get, Query, Param } from "@nestjs/common"; +import { DatabaseAnalyticsService } from "../database/services/database-analytics.service"; -@Controller('analytics') +@Controller("analytics") export class AnalyticsController { - constructor( - private readonly analyticsService: DatabaseAnalyticsService - ) {} + constructor(private readonly analyticsService: DatabaseAnalyticsService) {} - @Get('dashboard') + @Get("dashboard") async getDashboardAnalytics( - @Query('timeRange') timeRange: '24h' | '7d' | '30d' = '7d' + @Query("timeRange") timeRange: "24h" | "7d" | "30d" = "7d", ) { return this.analyticsService.getDashboardAnalytics(timeRange); } - @Get('merchants/:merchantId') + @Get("merchants/:merchantId") async getMerchantAnalytics( - @Param('merchantId') merchantId: string, - @Query('timeRange') timeRange: '24h' | '7d' | '30d' = '7d' + @Param("merchantId") merchantId: string, + @Query("timeRange") timeRange: "24h" | "7d" | "30d" = "7d", ) { return this.analyticsService.getMerchantAnalytics(merchantId, timeRange); } - @Get('chains/:chainId') + @Get("chains/:chainId") async getChainAnalytics( - @Param('chainId') chainId: string, - @Query('timeRange') timeRange: '24h' | '7d' | '30d' = '7d' + @Param("chainId") chainId: string, + @Query("timeRange") timeRange: "24h" | "7d" | "30d" = "7d", ) { return this.analyticsService.getChainAnalytics(chainId, timeRange); } - @Get('analysis') + @Get("analysis") async getAnalysisMetrics( - @Query('timeRange') timeRange: '24h' | '7d' | '30d' = '7d' + @Query("timeRange") timeRange: "24h" | "7d" | "30d" = "7d", ) { return this.analyticsService.getAnalysisMetrics(timeRange); } - @Get('performance') + @Get("performance") async getPerformanceMetrics() { return this.analyticsService.getPerformanceMetrics(); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/analytics/analytics.module.ts b/apps/api-service/src/analytics/analytics.module.ts index f056d69..060c5c5 100644 --- a/apps/api-service/src/analytics/analytics.module.ts +++ b/apps/api-service/src/analytics/analytics.module.ts @@ -1,18 +1,18 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { AnalyticsController } from './analytics.controller'; -import { SuspiciousGasController } from './controllers/suspicious-gas.controller'; -import { DatabaseAnalyticsService } from '../database/services/database-analytics.service'; -import { SuspiciousGasDetectionService } from './services/suspicious-gas-detection.service'; -import { SuspiciousGasPatternRepository } from './repositories/suspicious-gas-pattern.repository'; -import { GasBaselineRepository } from './repositories/gas-baseline.repository'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { AnalyticsController } from "./analytics.controller"; +import { SuspiciousGasController } from "./controllers/suspicious-gas.controller"; +import { DatabaseAnalyticsService } from "../database/services/database-analytics.service"; +import { SuspiciousGasDetectionService } from "./services/suspicious-gas-detection.service"; +import { SuspiciousGasPatternRepository } from "./repositories/suspicious-gas-pattern.repository"; +import { GasBaselineRepository } from "./repositories/gas-baseline.repository"; import { SuspiciousGasPattern, GasPatternDetectionLog, -} from './entities/suspicious-gas-pattern.entity'; -import { GasBaseline } from './entities/gas-baseline.entity'; -import { Transaction } from '../database/entities/transaction.entity'; +} from "./entities/suspicious-gas-pattern.entity"; +import { GasBaseline } from "./entities/gas-baseline.entity"; +import { Transaction } from "../database/entities/transaction.entity"; @Module({ imports: [ @@ -37,4 +37,4 @@ import { Transaction } from '../database/entities/transaction.entity'; GasBaselineRepository, ], }) -export class AnalyticsModule {} \ No newline at end of file +export class AnalyticsModule {} diff --git a/apps/api-service/src/analytics/controllers/suspicious-gas.controller.ts b/apps/api-service/src/analytics/controllers/suspicious-gas.controller.ts index 721bfc1..aa1bf42 100644 --- a/apps/api-service/src/analytics/controllers/suspicious-gas.controller.ts +++ b/apps/api-service/src/analytics/controllers/suspicious-gas.controller.ts @@ -7,8 +7,8 @@ import { Query, HttpCode, HttpStatus, -} from '@nestjs/common'; -import { SuspiciousGasPatternRepository } from '../repositories/suspicious-gas-pattern.repository'; +} from "@nestjs/common"; +import { SuspiciousGasPatternRepository } from "../repositories/suspicious-gas-pattern.repository"; import { QuerySuspiciousGasDto, ReviewPatternDto, @@ -16,19 +16,17 @@ import { ConfirmAbuseDto, SuspiciousGasPatternListResponseDto, SuspiciousGasStatsResponseDto, -} from '../dto/suspicious-gas.dto'; -import { PatternStatus } from '../entities/suspicious-gas-pattern.entity'; +} from "../dto/suspicious-gas.dto"; +import { PatternStatus } from "../entities/suspicious-gas-pattern.entity"; -@Controller('analytics/suspicious-gas') +@Controller("analytics/suspicious-gas") export class SuspiciousGasController { constructor( private readonly patternRepository: SuspiciousGasPatternRepository, ) {} @Get() - async findAll( - @Query() query: QuerySuspiciousGasDto, - ) { + async findAll(@Query() query: QuerySuspiciousGasDto) { const { data, total } = await this.patternRepository.findWithFilters({ chainId: query.chainId, severity: query.severity, @@ -47,7 +45,7 @@ export class SuspiciousGasController { }; } - @Get('stats') + @Get("stats") async getStats(): Promise { const stats = await this.patternRepository.getFlaggedAccountsStats(); const byChain = await this.patternRepository.getPatternsByChain(); @@ -58,11 +56,11 @@ export class SuspiciousGasController { }; } - @Get(':account') - async findByAccount(@Param('account') account: string) { + @Get(":account") + async findByAccount(@Param("account") account: string) { const patterns = await this.patternRepository.find({ where: { accountAddress: account }, - order: { lastDetectedAt: 'DESC' }, + order: { lastDetectedAt: "DESC" }, }); return { @@ -71,39 +69,33 @@ export class SuspiciousGasController { }; } - @Post(':id/review') + @Post(":id/review") @HttpCode(HttpStatus.OK) - async reviewPattern( - @Param('id') id: string, - @Body() dto: ReviewPatternDto, - ) { + async reviewPattern(@Param("id") id: string, @Body() dto: ReviewPatternDto) { const pattern = await this.patternRepository.findOne({ where: { id } }); if (!pattern) { - return { success: false, message: 'Pattern not found' }; + return { success: false, message: "Pattern not found" }; } pattern.status = PatternStatus.REVIEWED; pattern.reviewedBy = dto.reviewerId; pattern.reviewedAt = new Date(); - pattern.reviewNotes = dto.notes || ''; + pattern.reviewNotes = dto.notes || ""; await this.patternRepository.save(pattern); return { success: true, - message: 'Pattern marked as under review', + message: "Pattern marked as under review", }; } - @Post(':id/clear') + @Post(":id/clear") @HttpCode(HttpStatus.OK) - async clearPattern( - @Param('id') id: string, - @Body() dto: ClearPatternDto, - ) { + async clearPattern(@Param("id") id: string, @Body() dto: ClearPatternDto) { const pattern = await this.patternRepository.findOne({ where: { id } }); if (!pattern) { - return { success: false, message: 'Pattern not found' }; + return { success: false, message: "Pattern not found" }; } pattern.status = PatternStatus.CLEARED; @@ -115,31 +107,28 @@ export class SuspiciousGasController { return { success: true, - message: 'Pattern cleared as false positive', + message: "Pattern cleared as false positive", }; } - @Post(':id/confirm') + @Post(":id/confirm") @HttpCode(HttpStatus.OK) - async confirmAbuse( - @Param('id') id: string, - @Body() dto: ConfirmAbuseDto, - ) { + async confirmAbuse(@Param("id") id: string, @Body() dto: ConfirmAbuseDto) { const pattern = await this.patternRepository.findOne({ where: { id } }); if (!pattern) { - return { success: false, message: 'Pattern not found' }; + return { success: false, message: "Pattern not found" }; } pattern.status = PatternStatus.CONFIRMED_ABUSE; pattern.reviewedBy = dto.reviewerId; pattern.reviewedAt = new Date(); - pattern.reviewNotes = `Confirmed abuse. Action: ${dto.action || 'none'}. ${dto.notes || ''}`; + pattern.reviewNotes = `Confirmed abuse. Action: ${dto.action || "none"}. ${dto.notes || ""}`; await this.patternRepository.save(pattern); return { success: true, - message: 'Abuse confirmed and restrictions applied', + message: "Abuse confirmed and restrictions applied", }; } } diff --git a/apps/api-service/src/analytics/dto/suspicious-gas.dto.ts b/apps/api-service/src/analytics/dto/suspicious-gas.dto.ts index 17e81bf..4b945be 100644 --- a/apps/api-service/src/analytics/dto/suspicious-gas.dto.ts +++ b/apps/api-service/src/analytics/dto/suspicious-gas.dto.ts @@ -1,5 +1,17 @@ -import { IsString, IsOptional, IsEnum, IsInt, IsNumber, Min, Max } from 'class-validator'; -import { SeverityLevel, PatternStatus, PatternType } from '../entities/suspicious-gas-pattern.entity'; +import { + IsString, + IsOptional, + IsEnum, + IsInt, + IsNumber, + Min, + Max, +} from "class-validator"; +import { + SeverityLevel, + PatternStatus, + PatternType, +} from "../entities/suspicious-gas-pattern.entity"; /** * DTO for querying suspicious gas patterns diff --git a/apps/api-service/src/analytics/entities/gas-baseline.entity.ts b/apps/api-service/src/analytics/entities/gas-baseline.entity.ts index 25e3bfe..362ebc2 100644 --- a/apps/api-service/src/analytics/entities/gas-baseline.entity.ts +++ b/apps/api-service/src/analytics/entities/gas-baseline.entity.ts @@ -1,51 +1,57 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, +} from "typeorm"; /** * Entity to store behavioral baselines per account for gas pattern detection */ -@Entity('gas_baselines') -@Index('idx_gb_account_chain', ['accountAddress', 'chainId']) -@Index('idx_gb_last_updated', ['lastUpdated']) +@Entity("gas_baselines") +@Index("idx_gb_account_chain", ["accountAddress", "chainId"]) +@Index("idx_gb_last_updated", ["lastUpdated"]) export class GasBaseline { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_gb_account') + @Column({ type: "varchar", length: 100 }) + @Index("idx_gb_account") accountAddress: string; - @Column({ type: 'integer' }) - @Index('idx_gb_chain_id') + @Column({ type: "integer" }) + @Index("idx_gb_chain_id") chainId: number; - @Column({ type: 'decimal', precision: 30, scale: 18, default: 0 }) + @Column({ type: "decimal", precision: 30, scale: 18, default: 0 }) avgGasUsed: number; - @Column({ type: 'decimal', precision: 30, scale: 18, default: 0 }) + @Column({ type: "decimal", precision: 30, scale: 18, default: 0 }) stdDevGasUsed: number; - @Column({ type: 'decimal', precision: 30, scale: 18, default: 0 }) + @Column({ type: "decimal", precision: 30, scale: 18, default: 0 }) avgGasPrice: number; - @Column({ type: 'decimal', precision: 10, scale: 4, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 4, default: 0 }) avgTransactionFrequency: number; // transactions per hour - @Column({ type: 'simple-array', nullable: true }) + @Column({ type: "simple-array", nullable: true }) commonTxTypes: string[]; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) sampleSize: number; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) lastUpdated: Date; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) firstTransactionAt: Date; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) lastTransactionAt: Date; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata: Record; @CreateDateColumn() diff --git a/apps/api-service/src/analytics/entities/suspicious-gas-pattern.entity.ts b/apps/api-service/src/analytics/entities/suspicious-gas-pattern.entity.ts index 4e8560b..f5385d0 100644 --- a/apps/api-service/src/analytics/entities/suspicious-gas-pattern.entity.ts +++ b/apps/api-service/src/analytics/entities/suspicious-gas-pattern.entity.ts @@ -1,97 +1,104 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; export enum SeverityLevel { - LOW = 'low', - MEDIUM = 'medium', - HIGH = 'high', + LOW = "low", + MEDIUM = "medium", + HIGH = "high", } export enum PatternStatus { - ACTIVE = 'active', - REVIEWED = 'reviewed', - CLEARED = 'cleared', - CONFIRMED_ABUSE = 'confirmed_abuse', + ACTIVE = "active", + REVIEWED = "reviewed", + CLEARED = "cleared", + CONFIRMED_ABUSE = "confirmed_abuse", } export enum PatternType { - ABNORMAL_GAS_USAGE = 'abnormal_gas_usage', - FREQUENCY_ANOMALY = 'frequency_anomaly', - GAS_PRICE_MANIPULATION = 'gas_price_manipulation', - CONTRACT_CALL_ABUSE = 'contract_call_abuse', - BOT_LIKE_BEHAVIOR = 'bot_like_behavior', + ABNORMAL_GAS_USAGE = "abnormal_gas_usage", + FREQUENCY_ANOMALY = "frequency_anomaly", + GAS_PRICE_MANIPULATION = "gas_price_manipulation", + CONTRACT_CALL_ABUSE = "contract_call_abuse", + BOT_LIKE_BEHAVIOR = "bot_like_behavior", } /** * Entity to store detected suspicious gas patterns */ -@Entity('suspicious_gas_patterns') -@Index('idx_sgp_account_chain', ['accountAddress', 'chainId']) -@Index('idx_sgp_severity', ['severity']) -@Index('idx_sgp_status', ['status']) -@Index('idx_sgp_created_at', ['createdAt']) +@Entity("suspicious_gas_patterns") +@Index("idx_sgp_account_chain", ["accountAddress", "chainId"]) +@Index("idx_sgp_severity", ["severity"]) +@Index("idx_sgp_status", ["status"]) +@Index("idx_sgp_created_at", ["createdAt"]) export class SuspiciousGasPattern { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_sgp_account') + @Column({ type: "varchar", length: 100 }) + @Index("idx_sgp_account") accountAddress: string; - @Column({ type: 'integer' }) - @Index('idx_sgp_chain_id') + @Column({ type: "integer" }) + @Index("idx_sgp_chain_id") chainId: number; @Column({ - type: 'enum', + type: "enum", enum: SeverityLevel, default: SeverityLevel.LOW, }) severity: SeverityLevel; @Column({ - type: 'enum', + type: "enum", enum: PatternType, }) patternType: PatternType; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) description: string; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) flaggedTransactions: number; - @Column({ type: 'decimal', precision: 30, scale: 18, default: 0 }) + @Column({ type: "decimal", precision: 30, scale: 18, default: 0 }) abnormalGasTotal: number; - @Column({ type: 'decimal', precision: 30, scale: 18, nullable: true }) + @Column({ type: "decimal", precision: 30, scale: 18, nullable: true }) baselineGasUsed: number; - @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + @Column({ type: "decimal", precision: 10, scale: 4, nullable: true }) deviationScore: number; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) firstDetectedAt: Date; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) lastDetectedAt: Date; @Column({ - type: 'enum', + type: "enum", enum: PatternStatus, default: PatternStatus.ACTIVE, }) status: PatternStatus; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) reviewedBy: string; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) reviewedAt: Date; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) reviewNotes: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata: Record; @CreateDateColumn() @@ -104,38 +111,38 @@ export class SuspiciousGasPattern { /** * Entity to store individual detection logs per transaction */ -@Entity('gas_pattern_detection_logs') -@Index('idx_gpdl_pattern', ['patternId']) -@Index('idx_gpdl_transaction', ['transactionHash']) +@Entity("gas_pattern_detection_logs") +@Index("idx_gpdl_pattern", ["patternId"]) +@Index("idx_gpdl_transaction", ["transactionHash"]) export class GasPatternDetectionLog { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'uuid' }) + @Column({ type: "uuid" }) patternId: string; - @Column({ type: 'varchar', length: 100 }) + @Column({ type: "varchar", length: 100 }) transactionHash: string; - @Column({ type: 'varchar', length: 100 }) + @Column({ type: "varchar", length: 100 }) accountAddress: string; - @Column({ type: 'integer' }) + @Column({ type: "integer" }) chainId: number; - @Column({ type: 'decimal', precision: 30, scale: 18 }) + @Column({ type: "decimal", precision: 30, scale: 18 }) gasUsed: number; - @Column({ type: 'decimal', precision: 30, scale: 18 }) + @Column({ type: "decimal", precision: 30, scale: 18 }) gasPrice: number; - @Column({ type: 'decimal', precision: 10, scale: 4 }) + @Column({ type: "decimal", precision: 10, scale: 4 }) deviationScore: number; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) detectionReason: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata: Record; @CreateDateColumn() diff --git a/apps/api-service/src/analytics/repositories/gas-baseline.repository.ts b/apps/api-service/src/analytics/repositories/gas-baseline.repository.ts index 0a39caf..1c729ab 100644 --- a/apps/api-service/src/analytics/repositories/gas-baseline.repository.ts +++ b/apps/api-service/src/analytics/repositories/gas-baseline.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { GasBaseline } from '../entities/gas-baseline.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { GasBaseline } from "../entities/gas-baseline.entity"; @EntityRepository(GasBaseline) export class GasBaselineRepository extends Repository { @@ -52,11 +52,13 @@ export class GasBaselineRepository extends Repository { * Delete old baselines */ async deleteOldBaselines(olderThanDays: number): Promise { - const cutoffDate = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000); + const cutoffDate = new Date( + Date.now() - olderThanDays * 24 * 60 * 60 * 1000, + ); const result = await this.createQueryBuilder() .delete() - .where('lastUpdated < :cutoff', { cutoff: cutoffDate }) + .where("lastUpdated < :cutoff", { cutoff: cutoffDate }) .execute(); return result.affected || 0; diff --git a/apps/api-service/src/analytics/repositories/suspicious-gas-pattern.repository.ts b/apps/api-service/src/analytics/repositories/suspicious-gas-pattern.repository.ts index a3e35cc..42c7c80 100644 --- a/apps/api-service/src/analytics/repositories/suspicious-gas-pattern.repository.ts +++ b/apps/api-service/src/analytics/repositories/suspicious-gas-pattern.repository.ts @@ -1,9 +1,9 @@ -import { EntityRepository, Repository } from 'typeorm'; +import { EntityRepository, Repository } from "typeorm"; import { SuspiciousGasPattern, SeverityLevel, PatternStatus, -} from '../entities/suspicious-gas-pattern.entity'; +} from "../entities/suspicious-gas-pattern.entity"; @EntityRepository(SuspiciousGasPattern) export class SuspiciousGasPatternRepository extends Repository { @@ -16,7 +16,7 @@ export class SuspiciousGasPatternRepository extends Repository { return this.findOne({ where: { accountAddress, chainId }, - order: { createdAt: 'DESC' }, + order: { createdAt: "DESC" }, }); } @@ -46,9 +46,9 @@ export class SuspiciousGasPatternRepository extends Repository { - const query = this.createQueryBuilder('pattern') - .where('pattern.severity = :severity', { severity }) - .orderBy('pattern.lastDetectedAt', 'DESC') + const query = this.createQueryBuilder("pattern") + .where("pattern.severity = :severity", { severity }) + .orderBy("pattern.lastDetectedAt", "DESC") .skip(offset) .take(limit); @@ -65,9 +65,9 @@ export class SuspiciousGasPatternRepository extends Repository { - const query = this.createQueryBuilder('pattern') - .where('pattern.status = :status', { status }) - .orderBy('pattern.lastDetectedAt', 'DESC') + const query = this.createQueryBuilder("pattern") + .where("pattern.status = :status", { status }) + .orderBy("pattern.lastDetectedAt", "DESC") .skip(offset) .take(limit); @@ -83,9 +83,9 @@ export class SuspiciousGasPatternRepository extends Repository { - const query = this.createQueryBuilder('pattern') - .where('pattern.status = :status', { status: PatternStatus.ACTIVE }) - .orderBy('pattern.severity', 'DESC') + const query = this.createQueryBuilder("pattern") + .where("pattern.status = :status", { status: PatternStatus.ACTIVE }) + .orderBy("pattern.severity", "DESC") .skip(offset) .take(limit); @@ -103,10 +103,10 @@ export class SuspiciousGasPatternRepository extends Repository { - const query = this.createQueryBuilder('pattern') - .where('pattern.createdAt >= :from', { from }) - .andWhere('pattern.createdAt <= :to', { to }) - .orderBy('pattern.createdAt', 'DESC') + const query = this.createQueryBuilder("pattern") + .where("pattern.createdAt >= :from", { from }) + .andWhere("pattern.createdAt <= :to", { to }) + .orderBy("pattern.createdAt", "DESC") .skip(offset) .take(limit); @@ -132,7 +132,12 @@ export class SuspiciousGasPatternRepository extends Repository { const totalFlags = await this.count(); @@ -147,12 +152,14 @@ export class SuspiciousGasPatternRepository extends Repository :twentyFourHoursAgo', { twentyFourHoursAgo }) + const recentPatterns = await this.createQueryBuilder("pattern") + .where("pattern.createdAt > :twentyFourHoursAgo", { twentyFourHoursAgo }) .getMany(); const recentDetections = recentPatterns.length; @@ -168,10 +175,10 @@ export class SuspiciousGasPatternRepository extends Repository> { - const results = await this.createQueryBuilder('pattern') - .select('pattern.chainId', 'chainId') - .addSelect('COUNT(*)', 'count') - .groupBy('pattern.chainId') + const results = await this.createQueryBuilder("pattern") + .select("pattern.chainId", "chainId") + .addSelect("COUNT(*)", "count") + .groupBy("pattern.chainId") .getRawMany(); const byChain: Record = {}; @@ -194,29 +201,33 @@ export class SuspiciousGasPatternRepository extends Repository { - const query = this.createQueryBuilder('pattern'); + const query = this.createQueryBuilder("pattern"); if (filters.chainId !== undefined) { - query.andWhere('pattern.chainId = :chainId', { chainId: filters.chainId }); + query.andWhere("pattern.chainId = :chainId", { + chainId: filters.chainId, + }); } if (filters.severity) { - query.andWhere('pattern.severity = :severity', { severity: filters.severity }); + query.andWhere("pattern.severity = :severity", { + severity: filters.severity, + }); } if (filters.status) { - query.andWhere('pattern.status = :status', { status: filters.status }); + query.andWhere("pattern.status = :status", { status: filters.status }); } if (filters.from) { - query.andWhere('pattern.createdAt >= :from', { from: filters.from }); + query.andWhere("pattern.createdAt >= :from", { from: filters.from }); } if (filters.to) { - query.andWhere('pattern.createdAt <= :to', { to: filters.to }); + query.andWhere("pattern.createdAt <= :to", { to: filters.to }); } - query.orderBy('pattern.lastDetectedAt', 'DESC'); + query.orderBy("pattern.lastDetectedAt", "DESC"); const limit = filters.limit || 50; const offset = filters.offset || 0; diff --git a/apps/api-service/src/analytics/services/suspicious-gas-detection.service.ts b/apps/api-service/src/analytics/services/suspicious-gas-detection.service.ts index 25ce656..5ea26ce 100644 --- a/apps/api-service/src/analytics/services/suspicious-gas-detection.service.ts +++ b/apps/api-service/src/analytics/services/suspicious-gas-detection.service.ts @@ -1,16 +1,16 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Transaction } from '../../database/entities/transaction.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { Transaction } from "../../database/entities/transaction.entity"; import { SuspiciousGasPattern, GasPatternDetectionLog, SeverityLevel, PatternStatus, PatternType, -} from '../entities/suspicious-gas-pattern.entity'; -import { GasBaseline } from '../entities/gas-baseline.entity'; +} from "../entities/suspicious-gas-pattern.entity"; +import { GasBaseline } from "../entities/gas-baseline.entity"; export interface DetectionResult { isSuspicious: boolean; @@ -52,10 +52,22 @@ export class SuspiciousGasDetectionService { private readonly transactionRepository: Repository, private readonly configService: ConfigService, ) { - this.zScoreThresholdLow = this.configService.get('SUSPICIOUS_GAS_ZSCORE_THRESHOLD_LOW', 2.0); - this.zScoreThresholdMedium = this.configService.get('SUSPICIOUS_GAS_ZSCORE_THRESHOLD_MEDIUM', 3.0); - this.zScoreThresholdHigh = this.configService.get('SUSPICIOUS_GAS_ZSCORE_THRESHOLD_HIGH', 5.0); - this.minBaselineSamples = this.configService.get('SUSPICIOUS_GAS_MIN_BASELINE_SAMPLES', 10); + this.zScoreThresholdLow = this.configService.get( + "SUSPICIOUS_GAS_ZSCORE_THRESHOLD_LOW", + 2.0, + ); + this.zScoreThresholdMedium = this.configService.get( + "SUSPICIOUS_GAS_ZSCORE_THRESHOLD_MEDIUM", + 3.0, + ); + this.zScoreThresholdHigh = this.configService.get( + "SUSPICIOUS_GAS_ZSCORE_THRESHOLD_HIGH", + 5.0, + ); + this.minBaselineSamples = this.configService.get( + "SUSPICIOUS_GAS_MIN_BASELINE_SAMPLES", + 10, + ); this.frequencyMultiplier = 10; // 10x normal frequency this.gasPriceMultiplier = 5; // 5x average gas price } @@ -63,7 +75,9 @@ export class SuspiciousGasDetectionService { /** * Process a new transaction for suspicious patterns */ - async processTransaction(transaction: TransactionData): Promise { + async processTransaction( + transaction: TransactionData, + ): Promise { try { // Get or compute baseline for the account const baseline = await this.getOrComputeBaseline( @@ -72,7 +86,10 @@ export class SuspiciousGasDetectionService { ); // Run detection algorithms - const gasUsageResult = await this.detectAbnormalGasUsage(transaction, baseline); + const gasUsageResult = await this.detectAbnormalGasUsage( + transaction, + baseline, + ); if (gasUsageResult.isSuspicious) { await this.flagAccount(transaction, gasUsageResult); return gasUsageResult; @@ -84,7 +101,10 @@ export class SuspiciousGasDetectionService { return frequencyResult; } - const gasPriceResult = await this.detectGasPriceManipulation(transaction, baseline); + const gasPriceResult = await this.detectGasPriceManipulation( + transaction, + baseline, + ); if (gasPriceResult.isSuspicious) { await this.flagAccount(transaction, gasPriceResult); return gasPriceResult; @@ -92,7 +112,10 @@ export class SuspiciousGasDetectionService { return null; } catch (error) { - this.logger.error('Error processing transaction for suspicious patterns', error); + this.logger.error( + "Error processing transaction for suspicious patterns", + error, + ); return null; } } @@ -108,7 +131,11 @@ export class SuspiciousGasDetectionService { return { isSuspicious: false } as DetectionResult; } - const zScore = this.computeZScore(transaction.gasUsed, baseline.avgGasUsed, baseline.stdDevGasUsed); + const zScore = this.computeZScore( + transaction.gasUsed, + baseline.avgGasUsed, + baseline.stdDevGasUsed, + ); if (zScore > this.zScoreThresholdHigh) { return { @@ -145,9 +172,11 @@ export class SuspiciousGasDetectionService { /** * Detect frequency anomalies (potential bot behavior) */ - async detectFrequencyAnomaly(transaction: TransactionData): Promise { + async detectFrequencyAnomaly( + transaction: TransactionData, + ): Promise { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - + const recentTxCount = await this.transactionRepository.count({ where: { merchantId: transaction.accountAddress, @@ -165,9 +194,13 @@ export class SuspiciousGasDetectionService { const baselineRate = baseline.avgTransactionFrequency; if (currentRate > baselineRate * this.frequencyMultiplier) { - const severity = currentRate > baselineRate * 50 ? SeverityLevel.HIGH : - currentRate > baselineRate * 20 ? SeverityLevel.MEDIUM : SeverityLevel.LOW; - + const severity = + currentRate > baselineRate * 50 + ? SeverityLevel.HIGH + : currentRate > baselineRate * 20 + ? SeverityLevel.MEDIUM + : SeverityLevel.LOW; + return { isSuspicious: true, severity, @@ -239,14 +272,21 @@ export class SuspiciousGasDetectionService { if (pattern) { // Update existing pattern pattern.flaggedTransactions += 1; - pattern.abnormalGasTotal = Number(pattern.abnormalGasTotal) + detectionResult.abnormalGasAmount; + pattern.abnormalGasTotal = + Number(pattern.abnormalGasTotal) + detectionResult.abnormalGasAmount; pattern.lastDetectedAt = new Date(); - pattern.deviationScore = Math.max(pattern.deviationScore || 0, detectionResult.deviationScore); - + pattern.deviationScore = Math.max( + pattern.deviationScore || 0, + detectionResult.deviationScore, + ); + // Upgrade severity if needed if (detectionResult.severity === SeverityLevel.HIGH) { pattern.severity = SeverityLevel.HIGH; - } else if (detectionResult.severity === SeverityLevel.MEDIUM && pattern.severity === SeverityLevel.LOW) { + } else if ( + detectionResult.severity === SeverityLevel.MEDIUM && + pattern.severity === SeverityLevel.LOW + ) { pattern.severity = SeverityLevel.MEDIUM; } @@ -315,16 +355,19 @@ export class SuspiciousGasDetectionService { /** * Compute behavioral baseline for an account */ - async computeBaseline(accountAddress: string, chainId: number): Promise { + async computeBaseline( + accountAddress: string, + chainId: number, + ): Promise { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const transactions = await this.transactionRepository - .createQueryBuilder('tx') - .where('tx.merchantId = :account', { account: accountAddress }) - .andWhere('tx.chainId = :chainId', { chainId: chainId.toString() }) - .andWhere('tx.createdAt > :thirtyDaysAgo', { thirtyDaysAgo }) - .andWhere('tx.status = :status', { status: 'success' }) - .orderBy('tx.createdAt', 'ASC') + .createQueryBuilder("tx") + .where("tx.merchantId = :account", { account: accountAddress }) + .andWhere("tx.chainId = :chainId", { chainId: chainId.toString() }) + .andWhere("tx.createdAt > :thirtyDaysAgo", { thirtyDaysAgo }) + .andWhere("tx.status = :status", { status: "success" }) + .orderBy("tx.createdAt", "ASC") .getMany(); if (transactions.length < this.minBaselineSamples) { @@ -342,13 +385,17 @@ export class SuspiciousGasDetectionService { // Calculate transaction frequency (per hour) const firstTx = transactions[0].createdAt; const lastTx = transactions[transactions.length - 1].createdAt; - const hoursSpan = Math.max(1, (lastTx.getTime() - firstTx.getTime()) / (1000 * 60 * 60)); + const hoursSpan = Math.max( + 1, + (lastTx.getTime() - firstTx.getTime()) / (1000 * 60 * 60), + ); const avgFrequency = transactions.length / hoursSpan; // Get common transaction types const txTypeCounts: Record = {}; transactions.forEach((tx) => { - txTypeCounts[tx.transactionType] = (txTypeCounts[tx.transactionType] || 0) + 1; + txTypeCounts[tx.transactionType] = + (txTypeCounts[tx.transactionType] || 0) + 1; }); const commonTxTypes = Object.entries(txTypeCounts) .sort((a, b) => b[1] - a[1]) @@ -377,7 +424,10 @@ export class SuspiciousGasDetectionService { /** * Update baseline for an account */ - async updateBaseline(accountAddress: string, chainId: number): Promise { + async updateBaseline( + accountAddress: string, + chainId: number, + ): Promise { return this.computeBaseline(accountAddress, chainId); } @@ -385,13 +435,15 @@ export class SuspiciousGasDetectionService { * Clear old resolved flags */ async clearOldFlags(olderThanDays: number = 30): Promise { - const cutoffDate = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000); + const cutoffDate = new Date( + Date.now() - olderThanDays * 24 * 60 * 60 * 1000, + ); const result = await this.patternRepository .createQueryBuilder() .delete() - .where('status = :status', { status: PatternStatus.CLEARED }) - .andWhere('updatedAt < :cutoff', { cutoff: cutoffDate }) + .where("status = :status", { status: PatternStatus.CLEARED }) + .andWhere("updatedAt < :cutoff", { cutoff: cutoffDate }) .execute(); return result.affected || 0; @@ -419,7 +471,8 @@ export class SuspiciousGasDetectionService { private calculateStdDev(values: number[], mean: number): number { if (values.length === 0) return 0; const squaredDiffs = values.map((val) => Math.pow(val - mean, 2)); - const avgSquaredDiff = squaredDiffs.reduce((sum, val) => sum + val, 0) / values.length; + const avgSquaredDiff = + squaredDiffs.reduce((sum, val) => sum + val, 0) / values.length; return Math.sqrt(avgSquaredDiff); } } diff --git a/apps/api-service/src/analyzer/analyzer.controller.ts b/apps/api-service/src/analyzer/analyzer.controller.ts index f59c53d..161ea5b 100644 --- a/apps/api-service/src/analyzer/analyzer.controller.ts +++ b/apps/api-service/src/analyzer/analyzer.controller.ts @@ -1,22 +1,27 @@ -import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { AnalyzerService } from './analyzer.service'; -import { AnalyzeRequestDto } from './dto/analyze-request.dto'; -import { AnalysisReport, StorageSavings } from './interfaces/analyzer.interface'; +import { Controller, Post, Body, HttpCode, HttpStatus } from "@nestjs/common"; +import { AnalyzerService } from "./analyzer.service"; +import { AnalyzeRequestDto } from "./dto/analyze-request.dto"; +import { + AnalysisReport, + StorageSavings, +} from "./interfaces/analyzer.interface"; -@Controller('analyzer') +@Controller("analyzer") export class AnalyzerController { constructor(private readonly analyzerService: AnalyzerService) {} - @Post('analyze') + @Post("analyze") @HttpCode(HttpStatus.OK) - async analyze(@Body() analyzeRequest: AnalyzeRequestDto): Promise { + async analyze( + @Body() analyzeRequest: AnalyzeRequestDto, + ): Promise { return this.analyzerService.analyzeCode( analyzeRequest.code, - analyzeRequest.source ?? 'remote-analysis', + analyzeRequest.source ?? "remote-analysis", ); } - @Post('storage-savings') + @Post("storage-savings") @HttpCode(HttpStatus.OK) async calculateStorageSavings( @Body() analyzeRequest: AnalyzeRequestDto, diff --git a/apps/api-service/src/analyzer/analyzer.module.ts b/apps/api-service/src/analyzer/analyzer.module.ts index 78b2d07..1249a43 100644 --- a/apps/api-service/src/analyzer/analyzer.module.ts +++ b/apps/api-service/src/analyzer/analyzer.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { AnalyzerController } from './analyzer.controller'; -import { AnalyzerService } from './analyzer.service'; -import { ScannerModule } from '../scanner/scanner.module'; +import { Module } from "@nestjs/common"; +import { AnalyzerController } from "./analyzer.controller"; +import { AnalyzerService } from "./analyzer.service"; +import { ScannerModule } from "../scanner/scanner.module"; @Module({ imports: [ScannerModule], diff --git a/apps/api-service/src/analyzer/analyzer.service.ts b/apps/api-service/src/analyzer/analyzer.service.ts index 7425c39..93107cd 100644 --- a/apps/api-service/src/analyzer/analyzer.service.ts +++ b/apps/api-service/src/analyzer/analyzer.service.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import { ScannerService } from '../scanner/scanner.service'; -import { RuleViolation } from '../scanner/interfaces/scanner.interface'; +import { Injectable } from "@nestjs/common"; +import { ScannerService } from "../scanner/scanner.service"; +import { RuleViolation } from "../scanner/interfaces/scanner.interface"; import { AnalysisReport, StorageSavings, FormattedViolation, -} from './interfaces/analyzer.interface'; +} from "./interfaces/analyzer.interface"; @Injectable() export class AnalyzerService { @@ -14,7 +14,9 @@ export class AnalyzerService { async analyzeCode(code: string, source: string): Promise { const scanResult = await this.scannerService.scanContent(code, source); const formattedViolations = this.formatViolations(scanResult.violations); - const storageSavings = this.calculateStorageSavingsFromViolations(scanResult.violations); + const storageSavings = this.calculateStorageSavingsFromViolations( + scanResult.violations, + ); return { source, @@ -27,7 +29,10 @@ export class AnalyzerService { } async calculateStorageSavings(code: string): Promise { - const scanResult = await this.scannerService.scanContent(code, 'storage-analysis'); + const scanResult = await this.scannerService.scanContent( + code, + "storage-analysis", + ); return this.calculateStorageSavingsFromViolations(scanResult.violations); } @@ -41,14 +46,14 @@ export class AnalyzerService { private getSeverityIcon(severity: string): string { switch (severity) { - case 'error': - return '🚨'; - case 'warning': - return '⚠️'; - case 'info': - return 'ℹ️'; + case "error": + return "🚨"; + case "warning": + return "⚠️"; + case "info": + return "ℹ️"; default: - return '📝'; + return "📝"; } } @@ -58,22 +63,24 @@ export class AnalyzerService { private generateSummary(violations: RuleViolation[]): string { if (violations.length === 0) { - return '✅ No violations found! Your contract is optimized.'; + return "✅ No violations found! Your contract is optimized."; } - const errors = violations.filter((v) => v.severity === 'error').length; - const warnings = violations.filter((v) => v.severity === 'warning').length; - const info = violations.filter((v) => v.severity === 'info').length; + const errors = violations.filter((v) => v.severity === "error").length; + const warnings = violations.filter((v) => v.severity === "warning").length; + const info = violations.filter((v) => v.severity === "info").length; return `Scan Summary: ${violations.length} total violations (${errors} errors, ${warnings} warnings, ${info} info)`; } - private calculateStorageSavingsFromViolations(violations: RuleViolation[]): StorageSavings { + private calculateStorageSavingsFromViolations( + violations: RuleViolation[], + ): StorageSavings { let unusedVariables = 0; let estimatedSavingsKb = 0; for (const violation of violations) { - if (violation.ruleName === 'unused-state-variables') { + if (violation.ruleName === "unused-state-variables") { unusedVariables++; estimatedSavingsKb += 2.5; } @@ -89,7 +96,7 @@ export class AnalyzerService { private generateRecommendations(violations: RuleViolation[]): string[] { const recommendations: string[] = []; const unusedVars = violations.filter( - (v) => v.ruleName === 'unused-state-variables', + (v) => v.ruleName === "unused-state-variables", ).length; if (unusedVars > 0) { @@ -97,16 +104,16 @@ export class AnalyzerService { `Remove ${unusedVars} unused state variables to reduce storage costs`, ); recommendations.push( - 'Consider using more efficient data types where possible', + "Consider using more efficient data types where possible", ); recommendations.push( - 'Implement lazy loading patterns for rarely accessed data', + "Implement lazy loading patterns for rarely accessed data", ); } if (violations.length === 0) { recommendations.push( - 'Your contract looks good! Consider regular audits to maintain code quality.', + "Your contract looks good! Consider regular audits to maintain code quality.", ); } diff --git a/apps/api-service/src/analyzer/dto/analyze-request.dto.ts b/apps/api-service/src/analyzer/dto/analyze-request.dto.ts index 2f4633e..4534efc 100644 --- a/apps/api-service/src/analyzer/dto/analyze-request.dto.ts +++ b/apps/api-service/src/analyzer/dto/analyze-request.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; export class AnalyzeRequestDto { @IsString() @@ -7,5 +7,5 @@ export class AnalyzeRequestDto { @IsString() @IsOptional() - source?: string = 'remote-analysis'; + source?: string = "remote-analysis"; } diff --git a/apps/api-service/src/analyzer/incremental-analyzer-simple.service.ts b/apps/api-service/src/analyzer/incremental-analyzer-simple.service.ts index bc20b5c..4d6eeb9 100644 --- a/apps/api-service/src/analyzer/incremental-analyzer-simple.service.ts +++ b/apps/api-service/src/analyzer/incremental-analyzer-simple.service.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ScannerService } from '../scanner/scanner.service'; -import { RuleViolation } from '../scanner/interfaces/scanner.interface'; +import { Injectable, Logger } from "@nestjs/common"; +import { ScannerService } from "../scanner/scanner.service"; +import { RuleViolation } from "../scanner/interfaces/scanner.interface"; import { AnalysisReport, StorageSavings, FormattedViolation, -} from './interfaces/analyzer.interface'; +} from "./interfaces/analyzer.interface"; export interface IncrementalAnalysisOptions { useIncremental?: boolean; @@ -29,9 +29,7 @@ export class IncrementalAnalyzerSimpleService { private readonly logger = new Logger(IncrementalAnalyzerSimpleService.name); private readonly cache = new Map(); - constructor( - private readonly scannerService: ScannerService, - ) {} + constructor(private readonly scannerService: ScannerService) {} /** * Analyze code with incremental support @@ -39,13 +37,13 @@ export class IncrementalAnalyzerSimpleService { async analyzeCodeIncremental( code: string, source: string, - options: IncrementalAnalysisOptions = {} + options: IncrementalAnalysisOptions = {}, ): Promise { const startTime = Date.now(); - + // For single file analysis, always use full analysis const result = await this.analyzeCode(code, source); - + return { ...result, incrementalStats: { @@ -63,29 +61,32 @@ export class IncrementalAnalyzerSimpleService { */ async analyzeRepositoryIncremental( repoPath: string, - options: IncrementalAnalysisOptions = {} + options: IncrementalAnalysisOptions = {}, ): Promise { const startTime = Date.now(); - const useIncremental = options.useIncremental !== false && !options.forceFull; - + const useIncremental = + options.useIncremental !== false && !options.forceFull; + try { // For now, implement a simple version that always does full analysis // This can be enhanced later with proper incremental functionality this.logger.log(`Performing repository analysis for: ${repoPath}`); - + // Simulate finding files (in real implementation, this would scan the directory) const files = await this.findSupportedFiles(repoPath); - + if (!useIncremental || files.length <= 10) { return this.performFullAnalysis(repoPath, files, startTime); } - + // For now, fallback to full analysis // TODO: Implement proper incremental analysis with file hashing return this.performFullAnalysis(repoPath, files, startTime); - } catch (error) { - this.logger.error(`Repository analysis failed: ${error.message}`, error.stack); + this.logger.error( + `Repository analysis failed: ${error.message}`, + error.stack, + ); throw error; } } @@ -96,32 +97,37 @@ export class IncrementalAnalyzerSimpleService { private async performFullAnalysis( repoPath: string, files: string[], - startTime: number + startTime: number, ): Promise { this.logger.log(`Performing full analysis on ${files.length} files`); - + const allViolations: RuleViolation[] = []; const analysisTime = Date.now() - startTime; - + // Analyze files in batches const batchSize = 50; for (let i = 0; i < files.length; i += batchSize) { const batch = files.slice(i, i + batchSize); - + for (const filePath of batch) { try { // In a real implementation, this would read the file content // For now, we'll simulate the analysis - const scanResult = await this.scannerService.scanContent('', filePath); + const scanResult = await this.scannerService.scanContent( + "", + filePath, + ); allViolations.push(...scanResult.violations); } catch (error) { - this.logger.warn(`Failed to analyze file ${filePath}: ${error.message}`); + this.logger.warn( + `Failed to analyze file ${filePath}: ${error.message}`, + ); } } } - + const report = this.createAnalysisReport(repoPath, allViolations); - + return { ...report, incrementalStats: { @@ -137,9 +143,13 @@ export class IncrementalAnalyzerSimpleService { /** * Create analysis report from violations */ - private createAnalysisReport(repoPath: string, violations: RuleViolation[]): AnalysisReport { + private createAnalysisReport( + repoPath: string, + violations: RuleViolation[], + ): AnalysisReport { const formattedViolations = this.formatViolations(violations); - const storageSavings = this.calculateStorageSavingsFromViolations(violations); + const storageSavings = + this.calculateStorageSavingsFromViolations(violations); return { source: repoPath, @@ -157,12 +167,12 @@ export class IncrementalAnalyzerSimpleService { private async findSupportedFiles(repoPath: string): Promise { // This is a simplified implementation // In a real scenario, this would use fs.readdir and fs.stat to walk the directory - const supportedExtensions = ['.rs', '.sol', '.vy']; - + const supportedExtensions = [".rs", ".sol", ".vy"]; + // For now, return an empty array as a placeholder // In a real implementation, this would scan the directory recursively this.logger.log(`Scanning for supported files in ${repoPath}`); - + return []; } @@ -189,9 +199,11 @@ export class IncrementalAnalyzerSimpleService { */ async clearCache(repoPath: string): Promise { // Clear cache entries for this repository - const keysToDelete = Array.from(this.cache.keys()).filter(key => key.includes(repoPath)); - keysToDelete.forEach(key => this.cache.delete(key)); - + const keysToDelete = Array.from(this.cache.keys()).filter((key) => + key.includes(repoPath), + ); + keysToDelete.forEach((key) => this.cache.delete(key)); + this.logger.log(`Cleared incremental analysis cache for ${repoPath}`); } @@ -200,21 +212,26 @@ export class IncrementalAnalyzerSimpleService { */ async invalidateFiles(repoPath: string, filePaths: string[]): Promise { // Invalidate specific file cache entries - filePaths.forEach(filePath => { + filePaths.forEach((filePath) => { const key = `${repoPath}:${filePath}`; this.cache.delete(key); }); - + this.logger.log(`Invalidated cache for ${filePaths.length} files`); } /** * Legacy method for backward compatibility */ - private async analyzeCode(code: string, source: string): Promise { + private async analyzeCode( + code: string, + source: string, + ): Promise { const scanResult = await this.scannerService.scanContent(code, source); const formattedViolations = this.formatViolations(scanResult.violations); - const storageSavings = this.calculateStorageSavingsFromViolations(scanResult.violations); + const storageSavings = this.calculateStorageSavingsFromViolations( + scanResult.violations, + ); return { source, @@ -236,14 +253,14 @@ export class IncrementalAnalyzerSimpleService { private getSeverityIcon(severity: string): string { switch (severity) { - case 'error': - return '🚨'; - case 'warning': - return '⚠️'; - case 'info': - return 'ℹ️'; + case "error": + return "🚨"; + case "warning": + return "⚠️"; + case "info": + return "ℹ️"; default: - return '📝'; + return "📝"; } } @@ -253,22 +270,24 @@ export class IncrementalAnalyzerSimpleService { private generateSummary(violations: RuleViolation[]): string { if (violations.length === 0) { - return '✅ No violations found! Your contract is optimized.'; + return "✅ No violations found! Your contract is optimized."; } - const errors = violations.filter((v) => v.severity === 'error').length; - const warnings = violations.filter((v) => v.severity === 'warning').length; - const info = violations.filter((v) => v.severity === 'info').length; + const errors = violations.filter((v) => v.severity === "error").length; + const warnings = violations.filter((v) => v.severity === "warning").length; + const info = violations.filter((v) => v.severity === "info").length; return `Scan Summary: ${violations.length} total violations (${errors} errors, ${warnings} warnings, ${info} info)`; } - private calculateStorageSavingsFromViolations(violations: RuleViolation[]): StorageSavings { + private calculateStorageSavingsFromViolations( + violations: RuleViolation[], + ): StorageSavings { let unusedVariables = 0; let estimatedSavingsKb = 0; for (const violation of violations) { - if (violation.ruleName === 'unused-state-variables') { + if (violation.ruleName === "unused-state-variables") { unusedVariables++; estimatedSavingsKb += 2.5; } @@ -284,7 +303,7 @@ export class IncrementalAnalyzerSimpleService { private generateRecommendations(violations: RuleViolation[]): string[] { const recommendations: string[] = []; const unusedVars = violations.filter( - (v) => v.ruleName === 'unused-state-variables', + (v) => v.ruleName === "unused-state-variables", ).length; if (unusedVars > 0) { @@ -292,16 +311,16 @@ export class IncrementalAnalyzerSimpleService { `Remove ${unusedVars} unused state variables to reduce storage costs`, ); recommendations.push( - 'Consider using more efficient data types where possible', + "Consider using more efficient data types where possible", ); recommendations.push( - 'Implement lazy loading patterns for rarely accessed data', + "Implement lazy loading patterns for rarely accessed data", ); } if (violations.length === 0) { recommendations.push( - 'Your contract looks good! Consider regular audits to maintain code quality.', + "Your contract looks good! Consider regular audits to maintain code quality.", ); } diff --git a/apps/api-service/src/analyzer/incremental-analyzer.service.ts b/apps/api-service/src/analyzer/incremental-analyzer.service.ts index aba6bf5..d267405 100644 --- a/apps/api-service/src/analyzer/incremental-analyzer.service.ts +++ b/apps/api-service/src/analyzer/incremental-analyzer.service.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ScannerService } from '../scanner/scanner.service'; -import { RuleViolation } from '../scanner/interfaces/scanner.interface'; +import { Injectable, Logger } from "@nestjs/common"; +import { ScannerService } from "../scanner/scanner.service"; +import { RuleViolation } from "../scanner/interfaces/scanner.interface"; import { AnalysisReport, StorageSavings, FormattedViolation, -} from './interfaces/analyzer.interface'; +} from "./interfaces/analyzer.interface"; export interface IncrementalAnalysisOptions { useIncremental?: boolean; @@ -29,9 +29,7 @@ export class IncrementalAnalyzerService { private readonly logger = new Logger(IncrementalAnalyzerService.name); private readonly cache = new Map(); - constructor( - private readonly scannerService: ScannerService, - ) {} + constructor(private readonly scannerService: ScannerService) {} /** * Analyze code with incremental support @@ -39,13 +37,13 @@ export class IncrementalAnalyzerService { async analyzeCodeIncremental( code: string, source: string, - options: IncrementalAnalysisOptions = {} + options: IncrementalAnalysisOptions = {}, ): Promise { const startTime = Date.now(); - + // For single file analysis, always use full analysis const result = await this.analyzeCode(code, source); - + return { ...result, incrementalStats: { @@ -63,38 +61,47 @@ export class IncrementalAnalyzerService { */ async analyzeRepositoryIncremental( repoPath: string, - options: IncrementalAnalysisOptions = {} + options: IncrementalAnalysisOptions = {}, ): Promise { const startTime = Date.now(); - const useIncremental = options.useIncremental !== false && !options.forceFull; - + const useIncremental = + options.useIncremental !== false && !options.forceFull; + try { // Find all supported files in the repository const allFiles = await this.findSupportedFiles(repoPath); - + this.logger.log(`Found ${allFiles.length} supported files in repository`); - + if (!useIncremental || allFiles.length <= 10) { // Use full analysis for small repositories or when incremental is disabled return this.performFullAnalysis(repoPath, allFiles, startTime); } - + // Check if incremental analysis should be used - const shouldUseIncremental = await this.incrementalCacheService.shouldUseIncrementalAnalysis( - repoPath, - allFiles.length - ); - + const shouldUseIncremental = + await this.incrementalCacheService.shouldUseIncrementalAnalysis( + repoPath, + allFiles.length, + ); + if (!shouldUseIncremental) { - this.logger.log('Using full analysis (incremental not beneficial)'); + this.logger.log("Using full analysis (incremental not beneficial)"); return this.performFullAnalysis(repoPath, allFiles, startTime); } - + // Perform incremental analysis - return this.performIncrementalAnalysis(repoPath, allFiles, startTime, options); - + return this.performIncrementalAnalysis( + repoPath, + allFiles, + startTime, + options, + ); } catch (error) { - this.logger.error(`Repository analysis failed: ${error.message}`, error.stack); + this.logger.error( + `Repository analysis failed: ${error.message}`, + error.stack, + ); throw error; } } @@ -105,31 +112,36 @@ export class IncrementalAnalyzerService { private async performFullAnalysis( repoPath: string, files: string[], - startTime: number + startTime: number, ): Promise { this.logger.log(`Performing full analysis on ${files.length} files`); - + const allViolations: RuleViolation[] = []; const analysisTime = Date.now() - startTime; - + // Analyze files in batches to avoid overwhelming the system const batchSize = 50; for (let i = 0; i < files.length; i += batchSize) { const batch = files.slice(i, i + batchSize); - + for (const filePath of batch) { try { - const content = await fs.readFile(filePath, 'utf-8'); - const scanResult = await this.scannerService.scanContent(content, filePath); + const content = await fs.readFile(filePath, "utf-8"); + const scanResult = await this.scannerService.scanContent( + content, + filePath, + ); allViolations.push(...scanResult.violations); } catch (error) { - this.logger.warn(`Failed to analyze file ${filePath}: ${error.message}`); + this.logger.warn( + `Failed to analyze file ${filePath}: ${error.message}`, + ); } } } - + const report = this.createAnalysisReport(repoPath, allViolations); - + return { ...report, incrementalStats: { @@ -149,46 +161,54 @@ export class IncrementalAnalyzerService { repoPath: string, allFiles: string[], startTime: number, - options: IncrementalAnalysisOptions + options: IncrementalAnalysisOptions, ): Promise { - this.logger.log(`Performing incremental analysis on ${allFiles.length} files`); - + this.logger.log( + `Performing incremental analysis on ${allFiles.length} files`, + ); + try { - const incrementalResult = await this.incrementalCacheService.performIncrementalAnalysis( - repoPath, - allFiles, - async (filesToAnalyze: string[]) => { - const results = []; - - for (const filePath of filesToAnalyze) { - try { - const content = await fs.readFile(filePath, 'utf-8'); - const scanResult = await this.scannerService.scanContent(content, filePath); - results.push(scanResult); - } catch (error) { - this.logger.warn(`Failed to analyze file ${filePath}: ${error.message}`); + const incrementalResult = + await this.incrementalCacheService.performIncrementalAnalysis( + repoPath, + allFiles, + async (filesToAnalyze: string[]) => { + const results = []; + + for (const filePath of filesToAnalyze) { + try { + const content = await fs.readFile(filePath, "utf-8"); + const scanResult = await this.scannerService.scanContent( + content, + filePath, + ); + results.push(scanResult); + } catch (error) { + this.logger.warn( + `Failed to analyze file ${filePath}: ${error.message}`, + ); + } } - } - - return results; - } - ); - + + return results; + }, + ); + // Combine cached and new results const allViolations: RuleViolation[] = []; - + // Add cached results for (const cachedEntry of incrementalResult.cachedResults) { allViolations.push(...cachedEntry.analysisResult.violations); } - + // Add new results for (const newResult of incrementalResult.newResults) { allViolations.push(...newResult.violations); } - + const report = this.createAnalysisReport(repoPath, allViolations); - + return { ...report, incrementalStats: { @@ -199,9 +219,10 @@ export class IncrementalAnalyzerService { isIncremental: true, }, }; - } catch (error) { - this.logger.error(`Incremental analysis failed, falling back to full analysis: ${error.message}`); + this.logger.error( + `Incremental analysis failed, falling back to full analysis: ${error.message}`, + ); // Fallback to full analysis return this.performFullAnalysis(repoPath, allFiles, startTime); } @@ -210,9 +231,13 @@ export class IncrementalAnalyzerService { /** * Create analysis report from violations */ - private createAnalysisReport(repoPath: string, violations: RuleViolation[]): AnalysisReport { + private createAnalysisReport( + repoPath: string, + violations: RuleViolation[], + ): AnalysisReport { const formattedViolations = this.formatViolations(violations); - const storageSavings = this.calculateStorageSavingsFromViolations(violations); + const storageSavings = + this.calculateStorageSavingsFromViolations(violations); return { source: repoPath, @@ -228,19 +253,23 @@ export class IncrementalAnalyzerService { * Find all supported files in a directory */ private async findSupportedFiles(repoPath: string): Promise { - const supportedExtensions = ['.rs', '.sol', '.vy']; + const supportedExtensions = [".rs", ".sol", ".vy"]; const files: string[] = []; - + const walkDirectory = async (dirPath: string): Promise => { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); - + for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); - + if (entry.isDirectory()) { // Skip common directories that don't contain source code - if (['node_modules', '.git', 'target', 'dist', 'build'].includes(entry.name)) { + if ( + ["node_modules", ".git", "target", "dist", "build"].includes( + entry.name, + ) + ) { continue; } await walkDirectory(fullPath); @@ -255,7 +284,7 @@ export class IncrementalAnalyzerService { // Skip directories we can't read } }; - + await walkDirectory(repoPath); return files; } @@ -291,10 +320,15 @@ export class IncrementalAnalyzerService { /** * Legacy method for backward compatibility */ - private async analyzeCode(code: string, source: string): Promise { + private async analyzeCode( + code: string, + source: string, + ): Promise { const scanResult = await this.scannerService.scanContent(code, source); const formattedViolations = this.formatViolations(scanResult.violations); - const storageSavings = this.calculateStorageSavingsFromViolations(scanResult.violations); + const storageSavings = this.calculateStorageSavingsFromViolations( + scanResult.violations, + ); return { source, @@ -316,14 +350,14 @@ export class IncrementalAnalyzerService { private getSeverityIcon(severity: string): string { switch (severity) { - case 'error': - return '🚨'; - case 'warning': - return '⚠️'; - case 'info': - return 'ℹ️'; + case "error": + return "🚨"; + case "warning": + return "⚠️"; + case "info": + return "ℹ️"; default: - return '📝'; + return "📝"; } } @@ -333,22 +367,24 @@ export class IncrementalAnalyzerService { private generateSummary(violations: RuleViolation[]): string { if (violations.length === 0) { - return '✅ No violations found! Your contract is optimized.'; + return "✅ No violations found! Your contract is optimized."; } - const errors = violations.filter((v) => v.severity === 'error').length; - const warnings = violations.filter((v) => v.severity === 'warning').length; - const info = violations.filter((v) => v.severity === 'info').length; + const errors = violations.filter((v) => v.severity === "error").length; + const warnings = violations.filter((v) => v.severity === "warning").length; + const info = violations.filter((v) => v.severity === "info").length; return `Scan Summary: ${violations.length} total violations (${errors} errors, ${warnings} warnings, ${info} info)`; } - private calculateStorageSavingsFromViolations(violations: RuleViolation[]): StorageSavings { + private calculateStorageSavingsFromViolations( + violations: RuleViolation[], + ): StorageSavings { let unusedVariables = 0; let estimatedSavingsKb = 0; for (const violation of violations) { - if (violation.ruleName === 'unused-state-variables') { + if (violation.ruleName === "unused-state-variables") { unusedVariables++; estimatedSavingsKb += 2.5; } @@ -364,7 +400,7 @@ export class IncrementalAnalyzerService { private generateRecommendations(violations: RuleViolation[]): string[] { const recommendations: string[] = []; const unusedVars = violations.filter( - (v) => v.ruleName === 'unused-state-variables', + (v) => v.ruleName === "unused-state-variables", ).length; if (unusedVars > 0) { @@ -372,16 +408,16 @@ export class IncrementalAnalyzerService { `Remove ${unusedVars} unused state variables to reduce storage costs`, ); recommendations.push( - 'Consider using more efficient data types where possible', + "Consider using more efficient data types where possible", ); recommendations.push( - 'Implement lazy loading patterns for rarely accessed data', + "Implement lazy loading patterns for rarely accessed data", ); } if (violations.length === 0) { recommendations.push( - 'Your contract looks good! Consider regular audits to maintain code quality.', + "Your contract looks good! Consider regular audits to maintain code quality.", ); } diff --git a/apps/api-service/src/analyzer/index.ts b/apps/api-service/src/analyzer/index.ts index 52108e4..18e96db 100644 --- a/apps/api-service/src/analyzer/index.ts +++ b/apps/api-service/src/analyzer/index.ts @@ -1,5 +1,5 @@ -export * from './analyzer.module'; -export * from './analyzer.controller'; -export * from './analyzer.service'; -export * from './dto/analyze-request.dto'; -export * from './interfaces/analyzer.interface'; +export * from "./analyzer.module"; +export * from "./analyzer.controller"; +export * from "./analyzer.service"; +export * from "./dto/analyze-request.dto"; +export * from "./interfaces/analyzer.interface"; diff --git a/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts b/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts index 48e54fb..40bdbae 100644 --- a/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts +++ b/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts @@ -1,4 +1,4 @@ -import { RuleViolation } from '../../scanner/interfaces/scanner.interface'; +import { RuleViolation } from "../../scanner/interfaces/scanner.interface"; export interface FormattedViolation extends RuleViolation { severityIcon: string; diff --git a/apps/api-service/src/app.module.ts b/apps/api-service/src/app.module.ts index c37908e..9f48fba 100644 --- a/apps/api-service/src/app.module.ts +++ b/apps/api-service/src/app.module.ts @@ -1,23 +1,23 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { APP_GUARD } from '@nestjs/core'; -import { HealthModule } from './health/health.module'; -import { ScannerModule } from './scanner/scanner.module'; -import { AnalyzerModule } from './analyzer/analyzer.module'; -import { RulesModule } from './rules/rules.module'; -import { DatabaseModule } from './database/database.module'; -import { AnalyticsModule } from './analytics/analytics.module'; -import { ReportsModule } from './reports/reports.module'; -import { OptimizationModule } from './optimization/optimization.module'; -import { GasEstimationModule } from './gas-estimation/gas-estimation.module'; -import { ChainReliabilityModule } from './chain-reliability/chain-reliability.module'; -import { PerformanceMonitoringModule } from './performance-monitoring/performance-monitoring.module'; -import { GasSubsidyModule } from './gas-subsidy/gas-subsidy.module'; -import { RbacModule, RolesGuard } from './rbac'; -import { AuthModule } from './auth'; -import databaseConfig from './config/database.config'; -import { AuditModule } from './audit'; -import { TransactionsModule } from './transection/transactions.module'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { APP_GUARD } from "@nestjs/core"; +import { HealthModule } from "./health/health.module"; +import { ScannerModule } from "./scanner/scanner.module"; +import { AnalyzerModule } from "./analyzer/analyzer.module"; +import { RulesModule } from "./rules/rules.module"; +import { DatabaseModule } from "./database/database.module"; +import { AnalyticsModule } from "./analytics/analytics.module"; +import { ReportsModule } from "./reports/reports.module"; +import { OptimizationModule } from "./optimization/optimization.module"; +import { GasEstimationModule } from "./gas-estimation/gas-estimation.module"; +import { ChainReliabilityModule } from "./chain-reliability/chain-reliability.module"; +import { PerformanceMonitoringModule } from "./performance-monitoring/performance-monitoring.module"; +import { GasSubsidyModule } from "./gas-subsidy/gas-subsidy.module"; +import { RbacModule, RolesGuard } from "./rbac"; +import { AuthModule } from "./auth"; +import databaseConfig from "./config/database.config"; +import { AuditModule } from "./audit"; +import { TransactionsModule } from "./transection/transactions.module"; @Module({ imports: [ @@ -28,9 +28,9 @@ import { TransactionsModule } from './transection/transactions.module'; DatabaseModule, AuthModule, RbacModule, - HealthModule, - ScannerModule, - AnalyzerModule, + HealthModule, + ScannerModule, + AnalyzerModule, RulesModule, AnalyticsModule, ReportsModule, diff --git a/apps/api-service/src/audit/__tests__/audit.controller.e2e.spec.ts b/apps/api-service/src/audit/__tests__/audit.controller.e2e.spec.ts index d5aa757..86b75d0 100644 --- a/apps/api-service/src/audit/__tests__/audit.controller.e2e.spec.ts +++ b/apps/api-service/src/audit/__tests__/audit.controller.e2e.spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuditLog, EventType, OutcomeStatus } from '../entities'; -import { AuditLogService } from '../services/audit-log.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { AuditLog, EventType, OutcomeStatus } from "../entities"; +import { AuditLogService } from "../services/audit-log.service"; -describe('AuditController (e2e)', () => { +describe("AuditController (e2e)", () => { let auditLogService: AuditLogService; beforeEach(async () => { @@ -25,17 +25,22 @@ describe('AuditController (e2e)', () => { auditLogService = moduleFixture.get(AuditLogService); }); - it('should be able to instantiate service', () => { - if (!auditLogService) throw new Error('Service not defined'); + it("should be able to instantiate service", () => { + if (!auditLogService) throw new Error("Service not defined"); }); - it('should have queryLogs method', () => { - if (typeof auditLogService.queryLogs !== 'function') throw new Error('No queryLogs'); + it("should have queryLogs method", () => { + if (typeof auditLogService.queryLogs !== "function") + throw new Error("No queryLogs"); }); - it('should support event types', () => { - if (!EventType.API_REQUEST || !EventType.API_KEY_CREATED || !OutcomeStatus.SUCCESS) { - throw new Error('Missing types'); + it("should support event types", () => { + if ( + !EventType.API_REQUEST || + !EventType.API_KEY_CREATED || + !OutcomeStatus.SUCCESS + ) { + throw new Error("Missing types"); } }); }); diff --git a/apps/api-service/src/audit/audit.module.ts b/apps/api-service/src/audit/audit.module.ts index 34bdda0..e0424d8 100644 --- a/apps/api-service/src/audit/audit.module.ts +++ b/apps/api-service/src/audit/audit.module.ts @@ -1,12 +1,18 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { AuditLog, ApiKey } from './entities'; -import { AuditLogService, AuditLogRepository, AuditEventEmitter, ApiKeyService, ApiKeyRepository } from './services'; -import { ApiKeyExpirationService } from './services/api-key-expiration.service'; -import { AuditController } from './controllers/audit.controller'; -import { ApiKeyController } from './controllers/api-key.controller'; -import { AuditInterceptor } from './interceptors'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { AuditLog, ApiKey } from "./entities"; +import { + AuditLogService, + AuditLogRepository, + AuditEventEmitter, + ApiKeyService, + ApiKeyRepository, +} from "./services"; +import { ApiKeyExpirationService } from "./services/api-key-expiration.service"; +import { AuditController } from "./controllers/audit.controller"; +import { ApiKeyController } from "./controllers/api-key.controller"; +import { AuditInterceptor } from "./interceptors"; @Module({ imports: [ @@ -23,6 +29,12 @@ import { AuditInterceptor } from './interceptors'; ApiKeyRepository, ApiKeyExpirationService, ], - exports: [AuditLogService, AuditEventEmitter, AuditInterceptor, ApiKeyService, ApiKeyRepository], + exports: [ + AuditLogService, + AuditEventEmitter, + AuditInterceptor, + ApiKeyService, + ApiKeyRepository, + ], }) export class AuditModule {} diff --git a/apps/api-service/src/audit/controllers/api-key.controller.ts b/apps/api-service/src/audit/controllers/api-key.controller.ts index dc36d15..197375e 100644 --- a/apps/api-service/src/audit/controllers/api-key.controller.ts +++ b/apps/api-service/src/audit/controllers/api-key.controller.ts @@ -8,21 +8,21 @@ import { Query, HttpCode, HttpStatus, -} from '@nestjs/common'; -import { ApiKeyService } from '../services/api-key.service'; +} from "@nestjs/common"; +import { ApiKeyService } from "../services/api-key.service"; import { CreateApiKeyDto, ListApiKeysQueryDto, RevokeApiKeyDto, RotateApiKeyDto, -} from '../dto/api-key.dto'; +} from "../dto/api-key.dto"; /** * API Key Management Controller * Handles creation, rotation, and revocation of API keys * Note: JWT Auth Guard should be applied at route level or globally */ -@Controller('api-keys') +@Controller("api-keys") export class ApiKeyController { constructor(private readonly apiKeyService: ApiKeyService) {} @@ -31,19 +31,17 @@ export class ApiKeyController { * POST /api-keys */ @Post() - async createApiKey( - @Body() createDto: CreateApiKeyDto, - req: any, - ) { + async createApiKey(@Body() createDto: CreateApiKeyDto, req: any) { // Get merchantId from authenticated user const merchantId = req.user?.merchantId || req.user?.sub; - + const result = await this.apiKeyService.createApiKey(merchantId, createDto); - + return { success: true, data: result, - message: 'API key created successfully. Store the key securely - it will not be shown again.', + message: + "API key created successfully. Store the key securely - it will not be shown again.", }; } @@ -52,19 +50,16 @@ export class ApiKeyController { * GET /api-keys */ @Get() - async listApiKeys( - @Query() query: ListApiKeysQueryDto, - req: any, - ) { + async listApiKeys(@Query() query: ListApiKeysQueryDto, req: any) { const merchantId = req.user?.merchantId || req.user?.sub; - + const result = await this.apiKeyService.listApiKeys( merchantId, query.limit, query.offset, query.status, ); - + return { success: true, data: result, @@ -75,15 +70,12 @@ export class ApiKeyController { * Get API key status * GET /api-keys/:id/status */ - @Get(':id/status') - async getApiKeyStatus( - @Param('id') keyId: string, - req: any, - ) { + @Get(":id/status") + async getApiKeyStatus(@Param("id") keyId: string, req: any) { const merchantId = req.user?.merchantId || req.user?.sub; - + const result = await this.apiKeyService.getApiKeyStatus(keyId, merchantId); - + return { success: true, data: result, @@ -94,24 +86,25 @@ export class ApiKeyController { * Rotate an API key * POST /api-keys/:id/rotate */ - @Post(':id/rotate') + @Post(":id/rotate") async rotateApiKey( - @Param('id') keyId: string, + @Param("id") keyId: string, @Body() rotateDto: RotateApiKeyDto, req: any, ) { const merchantId = req.user?.merchantId || req.user?.sub; - + const result = await this.apiKeyService.rotateApiKey( keyId, merchantId, rotateDto.reason, ); - + return { success: true, data: result, - message: 'API key rotated successfully. The old key will remain valid for 24 hours.', + message: + "API key rotated successfully. The old key will remain valid for 24 hours.", }; } @@ -119,20 +112,20 @@ export class ApiKeyController { * Revoke an API key * POST /api-keys/:id/revoke */ - @Post(':id/revoke') + @Post(":id/revoke") @HttpCode(HttpStatus.OK) async revokeApiKey( - @Param('id') keyId: string, + @Param("id") keyId: string, @Body() revokeDto: RevokeApiKeyDto, req: any, ) { const merchantId = req.user?.merchantId || req.user?.sub; - + await this.apiKeyService.revokeApiKey(keyId, merchantId, revokeDto.reason); - + return { success: true, - message: 'API key revoked successfully.', + message: "API key revoked successfully.", }; } @@ -140,18 +133,15 @@ export class ApiKeyController { * Delete (revoke) an API key * DELETE /api-keys/:id */ - @Delete(':id') - async deleteApiKey( - @Param('id') keyId: string, - req: any, - ) { + @Delete(":id") + async deleteApiKey(@Param("id") keyId: string, req: any) { const merchantId = req.user?.merchantId || req.user?.sub; - - await this.apiKeyService.revokeApiKey(keyId, merchantId, 'deleted-via-api'); - + + await this.apiKeyService.revokeApiKey(keyId, merchantId, "deleted-via-api"); + return { success: true, - message: 'API key deleted successfully.', + message: "API key deleted successfully.", }; } } diff --git a/apps/api-service/src/audit/controllers/audit.controller.ts b/apps/api-service/src/audit/controllers/audit.controller.ts index 693997d..2db5bb3 100644 --- a/apps/api-service/src/audit/controllers/audit.controller.ts +++ b/apps/api-service/src/audit/controllers/audit.controller.ts @@ -11,12 +11,12 @@ import { ForbiddenException, NotFoundException, BadRequestException, -} from '@nestjs/common'; +} from "@nestjs/common"; // import type { Response } from 'express'; -import { AuditLogService } from '../services/audit-log.service'; -import { AuditLogFilterDto, ExportAuditLogsDto } from '../dto/audit-log.dto'; +import { AuditLogService } from "../services/audit-log.service"; +import { AuditLogFilterDto, ExportAuditLogsDto } from "../dto/audit-log.dto"; -@Controller('audit') +@Controller("audit") export class AuditController { constructor(private readonly auditLogService: AuditLogService) {} @@ -25,7 +25,7 @@ export class AuditController { * Only accessible by admin users * GET /audit/logs?eventType=APIRequest&user=merchant-id&from=2024-01-01&to=2024-12-31 */ - @Get('logs') + @Get("logs") async getLogs(@Query() filters: AuditLogFilterDto) { // In production: Add @UseGuards(AdminGuard) to enforce admin-only access try { @@ -39,8 +39,8 @@ export class AuditController { /** * Get a specific audit log by ID */ - @Get('logs/:id') - async getLogById(@Param('id') id: string) { + @Get("logs/:id") + async getLogById(@Param("id") id: string) { // In production: Add @UseGuards(AdminGuard) to enforce admin-only access const log = await this.auditLogService.getLogById(id); if (!log) { @@ -52,10 +52,10 @@ export class AuditController { /** * Get logs by event type */ - @Get('logs/type/:eventType') + @Get("logs/type/:eventType") async getLogsByEventType( - @Param('eventType') eventType: string, - @Query('limit') limit?: number, + @Param("eventType") eventType: string, + @Query("limit") limit?: number, ) { // In production: Add @UseGuards(AdminGuard) to enforce admin-only access return this.auditLogService.getLogsByEventType(eventType as any, limit); @@ -64,10 +64,10 @@ export class AuditController { /** * Get logs for a specific user */ - @Get('logs/user/:userId') + @Get("logs/user/:userId") async getLogsByUser( - @Param('userId') userId: string, - @Query('limit') limit?: number, + @Param("userId") userId: string, + @Query("limit") limit?: number, ) { // In production: Add @UseGuards(AdminGuard) or similar to verify authorization return this.auditLogService.getLogsByUser(userId, limit); @@ -76,11 +76,9 @@ export class AuditController { /** * Export audit logs in CSV or JSON format */ - @Post('logs/export') + @Post("logs/export") @HttpCode(HttpStatus.OK) - async exportLogs( - @Body() exportDto: ExportAuditLogsDto, - ) { + async exportLogs(@Body() exportDto: ExportAuditLogsDto) { // In production: Add @UseGuards(AdminGuard) to enforce admin-only access try { const data = await this.auditLogService.exportLogs( @@ -101,11 +99,11 @@ export class AuditController { /** * Get audit statistics */ - @Get('stats') + @Get("stats") async getStats() { // In production: Add @UseGuards(AdminGuard) to enforce admin-only access const stats = { - message: 'Audit statistics endpoint', + message: "Audit statistics endpoint", // Implementation can include: event counts by type, user activity, etc. }; return stats; diff --git a/apps/api-service/src/audit/dto/api-key.dto.ts b/apps/api-service/src/audit/dto/api-key.dto.ts index 5ab7faf..99b5a79 100644 --- a/apps/api-service/src/audit/dto/api-key.dto.ts +++ b/apps/api-service/src/audit/dto/api-key.dto.ts @@ -1,5 +1,13 @@ -import { IsString, IsOptional, IsInt, IsEnum, Min, Max, IsUUID } from 'class-validator'; -import { ApiKeyStatus } from '../entities/api-key.entity'; +import { + IsString, + IsOptional, + IsInt, + IsEnum, + Min, + Max, + IsUUID, +} from "class-validator"; +import { ApiKeyStatus } from "../entities/api-key.entity"; /** * DTO for creating a new API key @@ -123,7 +131,7 @@ export class ApiKeyRotationResponseDto { * API Key Expired Error Response */ export interface ApiKeyExpiredError { - error: 'APIKeyExpired'; + error: "APIKeyExpired"; message: string; expiredAt: string; keyId: string; diff --git a/apps/api-service/src/audit/dto/audit-log.dto.ts b/apps/api-service/src/audit/dto/audit-log.dto.ts index 12b9a4f..b3e53af 100644 --- a/apps/api-service/src/audit/dto/audit-log.dto.ts +++ b/apps/api-service/src/audit/dto/audit-log.dto.ts @@ -1,5 +1,5 @@ -import { IsOptional, IsEnum, IsString } from 'class-validator'; -import { EventType, OutcomeStatus } from '../entities'; +import { IsOptional, IsEnum, IsString } from "class-validator"; +import { EventType, OutcomeStatus } from "../entities"; export class AuditLogFilterDto { @IsOptional() @@ -37,11 +37,11 @@ export class AuditLogFilterDto { @IsOptional() @IsString() - sortBy?: string = 'timestamp'; + sortBy?: string = "timestamp"; @IsOptional() @IsString() - sortOrder?: 'ASC' | 'DESC' = 'DESC'; + sortOrder?: "ASC" | "DESC" = "DESC"; } export class CreateAuditLogDto { @@ -85,7 +85,7 @@ export class AuditLogsPageDto { } export class ExportAuditLogsDto { - format: 'csv' | 'json'; + format: "csv" | "json"; eventType?: string; user?: string; from?: string; diff --git a/apps/api-service/src/audit/entities/api-key.entity.ts b/apps/api-service/src/audit/entities/api-key.entity.ts index 4f091af..6ec91f4 100644 --- a/apps/api-service/src/audit/entities/api-key.entity.ts +++ b/apps/api-service/src/audit/entities/api-key.entity.ts @@ -1,57 +1,63 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, +} from "typeorm"; export enum ApiKeyStatus { - ACTIVE = 'active', - ROTATED = 'rotated', - REVOKED = 'revoked', - EXPIRED = 'expired', + ACTIVE = "active", + ROTATED = "rotated", + REVOKED = "revoked", + EXPIRED = "expired", } -@Entity('api_keys') -@Index('idx_apikey_hash', ['keyHash']) -@Index('idx_apikey_merchant', ['merchantId']) -@Index('idx_apikey_status', ['status']) -@Index('idx_apikey_created', ['createdAt']) +@Entity("api_keys") +@Index("idx_apikey_hash", ["keyHash"]) +@Index("idx_apikey_merchant", ["merchantId"]) +@Index("idx_apikey_status", ["status"]) +@Index("idx_apikey_created", ["createdAt"]) export class ApiKey { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) + @Column({ type: "varchar", length: 100 }) merchantId: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) name: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) keyHash: string; // Hash of actual key (never store raw) - @Column({ type: 'enum', enum: ApiKeyStatus, default: ApiKeyStatus.ACTIVE }) + @Column({ type: "enum", enum: ApiKeyStatus, default: ApiKeyStatus.ACTIVE }) status: ApiKeyStatus; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) lastUsedAt: Date; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) requestCount: number; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) expiresAt: Date; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) description: string; - @Column({ type: 'varchar', length: 50, default: 'user' }) + @Column({ type: "varchar", length: 50, default: "user" }) role: string; // 'user', 'admin', 'read-only' - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata: Record; @CreateDateColumn() createdAt: Date; - @Column({ type: 'timestamp', onUpdate: 'CURRENT_TIMESTAMP' }) + @Column({ type: "timestamp", onUpdate: "CURRENT_TIMESTAMP" }) updatedAt: Date; - @Column({ type: 'uuid', nullable: true }) + @Column({ type: "uuid", nullable: true }) rotatedFromId: string; // Reference to previous key version } diff --git a/apps/api-service/src/audit/entities/audit-log.entity.ts b/apps/api-service/src/audit/entities/audit-log.entity.ts index 9a9c21c..77041c7 100644 --- a/apps/api-service/src/audit/entities/audit-log.entity.ts +++ b/apps/api-service/src/audit/entities/audit-log.entity.ts @@ -1,78 +1,84 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, +} from "typeorm"; export enum EventType { - API_REQUEST = 'APIRequest', - API_KEY_CREATED = 'KeyCreated', - API_KEY_ROTATED = 'KeyRotated', - API_KEY_REVOKED = 'KeyRevoked', - GAS_TRANSACTION = 'GasTransaction', - GAS_SUBMISSION = 'GasSubmission', + API_REQUEST = "APIRequest", + API_KEY_CREATED = "KeyCreated", + API_KEY_ROTATED = "KeyRotated", + API_KEY_REVOKED = "KeyRevoked", + GAS_TRANSACTION = "GasTransaction", + GAS_SUBMISSION = "GasSubmission", // Admin action events - CONFIG_UPDATE = 'ConfigUpdate', - ROLE_CHANGE = 'RoleChange', - TREASURY_OPERATION = 'TreasuryOperation', - SYSTEM_ADMIN = 'SystemAdmin', + CONFIG_UPDATE = "ConfigUpdate", + ROLE_CHANGE = "RoleChange", + TREASURY_OPERATION = "TreasuryOperation", + SYSTEM_ADMIN = "SystemAdmin", } export enum OutcomeStatus { - SUCCESS = 'success', - FAILURE = 'failure', - WARNING = 'warning', + SUCCESS = "success", + FAILURE = "failure", + WARNING = "warning", } -@Entity('audit_logs') -@Index('idx_audit_event_type', ['eventType']) -@Index('idx_audit_user', ['user']) -@Index('idx_audit_timestamp', ['timestamp']) -@Index('idx_audit_chain_id', ['chainId']) -@Index('idx_audit_composite', ['eventType', 'user', 'timestamp']) +@Entity("audit_logs") +@Index("idx_audit_event_type", ["eventType"]) +@Index("idx_audit_user", ["user"]) +@Index("idx_audit_timestamp", ["timestamp"]) +@Index("idx_audit_chain_id", ["chainId"]) +@Index("idx_audit_composite", ["eventType", "user", "timestamp"]) export class AuditLog { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'enum', enum: EventType }) + @Column({ type: "enum", enum: EventType }) eventType: EventType; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) timestamp: Date; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) user: string; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) apiKey: string; - @Column({ type: 'integer', nullable: true }) + @Column({ type: "integer", nullable: true }) chainId: number; - @Column({ type: 'jsonb' }) + @Column({ type: "jsonb" }) details: Record; - @Column({ type: 'enum', enum: OutcomeStatus }) + @Column({ type: "enum", enum: OutcomeStatus }) outcome: OutcomeStatus; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) endpoint: string; - @Column({ type: 'varchar', length: 10, nullable: true }) + @Column({ type: "varchar", length: 10, nullable: true }) httpMethod: string; - @Column({ type: 'integer', nullable: true }) + @Column({ type: "integer", nullable: true }) responseStatus: number; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) ipAddress: string; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) errorMessage: string; - @Column({ type: 'bigint', nullable: true }) + @Column({ type: "bigint", nullable: true }) responseDuration: number; // in milliseconds @CreateDateColumn() createdAt: Date; // Immutability marker - hash for integrity verification - @Column({ type: 'varchar', length: 64, nullable: true }) + @Column({ type: "varchar", length: 64, nullable: true }) integrity: string; } diff --git a/apps/api-service/src/audit/entities/index.ts b/apps/api-service/src/audit/entities/index.ts index c50b5e9..c92de3a 100644 --- a/apps/api-service/src/audit/entities/index.ts +++ b/apps/api-service/src/audit/entities/index.ts @@ -1,2 +1,2 @@ -export { AuditLog, EventType, OutcomeStatus } from './audit-log.entity'; -export { ApiKey, ApiKeyStatus } from './api-key.entity'; +export { AuditLog, EventType, OutcomeStatus } from "./audit-log.entity"; +export { ApiKey, ApiKeyStatus } from "./api-key.entity"; diff --git a/apps/api-service/src/audit/examples/audit-integration.example.ts b/apps/api-service/src/audit/examples/audit-integration.example.ts index 9a90a3d..9f6b5ce 100644 --- a/apps/api-service/src/audit/examples/audit-integration.example.ts +++ b/apps/api-service/src/audit/examples/audit-integration.example.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@nestjs/common'; -import { AuditLogService } from '../../audit/services/audit-log.service'; -import { EventType } from '../../audit/entities'; +import { Injectable } from "@nestjs/common"; +import { AuditLogService } from "../../audit/services/audit-log.service"; +import { EventType } from "../../audit/entities"; /** * EXAMPLE: Integration of Audit Logging in API Key Management Service @@ -16,10 +16,10 @@ export class ApiKeyManagementExample { async createApiKeyExample(merchantId: string, keyDetails: any) { // Business logic to create API key const newKey = { - id: 'key_' + Date.now(), + id: "key_" + Date.now(), name: keyDetails.name, - status: 'active', - role: keyDetails.role || 'user', + status: "active", + role: keyDetails.role || "user", createdAt: new Date(), }; @@ -39,7 +39,7 @@ export class ApiKeyManagementExample { } async rotateApiKeyExample(merchantId: string, oldKeyId: string) { - const newKeyId = 'key_' + Date.now(); + const newKeyId = "key_" + Date.now(); // Business logic to rotate key @@ -50,7 +50,7 @@ export class ApiKeyManagementExample { { oldKeyId, newKeyId, - reason: 'scheduled rotation', + reason: "scheduled rotation", timestamp: new Date(), }, ); @@ -67,7 +67,7 @@ export class ApiKeyManagementExample { merchantId, { revokedKeyId: keyId, - reason: reason || 'user-initiated', + reason: reason || "user-initiated", revokedAt: new Date(), }, ); @@ -91,12 +91,13 @@ export class GasTransactionServiceExample { ) { // Business logic to submit gas transaction const result = { - transactionHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + transactionHash: + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", gasUsed: 21000, - gasPrice: '45 gwei', - senderAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - method: 'transfer', - value: '1.5', + gasPrice: "45 gwei", + senderAddress: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + method: "transfer", + value: "1.5", }; // ✅ Emit audit event for gas transaction @@ -110,7 +111,7 @@ export class GasTransactionServiceExample { { method: result.method, value: result.value, - status: 'confirmed', + status: "confirmed", submittedAt: new Date(), }, ); @@ -124,10 +125,17 @@ export class GasTransactionServiceExample { amount: number, ) { // Business logic for subsidy submission - const submissionId = 'subsidy_' + Date.now(); + const submissionId = "subsidy_" + Date.now(); // ✅ Emit audit event for gas submission - this.auditLogService.emitApiRequest('test-key', '/api/test', 'POST', 200, undefined, undefined); + this.auditLogService.emitApiRequest( + "test-key", + "/api/test", + "POST", + 200, + undefined, + undefined, + ); return submissionId; } @@ -155,16 +163,18 @@ export class AuditReportingExample { return { merchantId, - period: 'last_30_days', + period: "last_30_days", totalEvents: logs.total, events: logs.data, summary: { - apiRequests: logs.data.filter((e) => e.eventType === 'APIRequest').length, + apiRequests: logs.data.filter((e) => e.eventType === "APIRequest") + .length, keyEvents: logs.data.filter((e) => - ['KeyCreated', 'KeyRotated', 'KeyRevoked'].includes(e.eventType), + ["KeyCreated", "KeyRotated", "KeyRevoked"].includes(e.eventType), + ).length, + gasTransactions: logs.data.filter( + (e) => e.eventType === "GasTransaction", ).length, - gasTransactions: logs.data.filter((e) => e.eventType === 'GasTransaction') - .length, }, }; } @@ -172,7 +182,7 @@ export class AuditReportingExample { async generateComplianceReport(fromDate: string, toDate: string) { // Get all key lifecycle events for compliance audit const keyCreations = await this.auditLogService.queryLogs({ - eventType: 'KeyCreated' as any, + eventType: "KeyCreated" as any, from: fromDate, to: toDate, limit: 10000, @@ -180,7 +190,7 @@ export class AuditReportingExample { }); const keyRotations = await this.auditLogService.queryLogs({ - eventType: 'KeyRotated' as any, + eventType: "KeyRotated" as any, from: fromDate, to: toDate, limit: 10000, @@ -188,7 +198,7 @@ export class AuditReportingExample { }); const keyRevocations = await this.auditLogService.queryLogs({ - eventType: 'KeyRevoked' as any, + eventType: "KeyRevoked" as any, from: fromDate, to: toDate, limit: 10000, @@ -212,8 +222,8 @@ export class AuditReportingExample { async getFailedRequestsReport(merchantId?: string) { const filters: any = { - eventType: 'APIRequest' as any, - outcome: 'failure' as any, + eventType: "APIRequest" as any, + outcome: "failure" as any, limit: 10000, offset: 0, }; @@ -229,10 +239,7 @@ export class AuditReportingExample { return this.auditLogService.queryLogs(filters); } - async exportAuditTrail( - format: 'csv' | 'json', - filters?: any, - ) { + async exportAuditTrail(format: "csv" | "json", filters?: any) { return this.auditLogService.exportLogs(format, filters); } } diff --git a/apps/api-service/src/audit/index.ts b/apps/api-service/src/audit/index.ts index 75e6bbe..4f23612 100644 --- a/apps/api-service/src/audit/index.ts +++ b/apps/api-service/src/audit/index.ts @@ -1,6 +1,6 @@ -export * from './audit.module'; -export * from './entities'; -export * from './services'; -export * from './controllers/audit.controller'; -export * from './interceptors'; -export * from './dto/audit-log.dto'; +export * from "./audit.module"; +export * from "./entities"; +export * from "./services"; +export * from "./controllers/audit.controller"; +export * from "./interceptors"; +export * from "./dto/audit-log.dto"; diff --git a/apps/api-service/src/audit/interceptors/__tests__/audit.interceptor.spec.ts b/apps/api-service/src/audit/interceptors/__tests__/audit.interceptor.spec.ts index 8b3dd69..9051ed9 100644 --- a/apps/api-service/src/audit/interceptors/__tests__/audit.interceptor.spec.ts +++ b/apps/api-service/src/audit/interceptors/__tests__/audit.interceptor.spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test, TestingModule } from "@nestjs/testing"; // import { INestApplication } from '@nestjs/common'; -import { AuditInterceptor } from '../../interceptors/audit.interceptor'; -import { AuditLogService } from '../../services/audit-log.service'; +import { AuditInterceptor } from "../../interceptors/audit.interceptor"; +import { AuditLogService } from "../../services/audit-log.service"; -describe('AuditInterceptor', () => { +describe("AuditInterceptor", () => { let interceptor: AuditInterceptor; let auditLogService: AuditLogService; @@ -15,93 +15,104 @@ describe('AuditInterceptor', () => { interceptor = new AuditInterceptor(auditLogService); }); - it('should be defined', () => { - if (!interceptor) throw new Error('Interceptor not defined'); + it("should be defined", () => { + if (!interceptor) throw new Error("Interceptor not defined"); }); - describe('API Key Extraction', () => { - it('should extract API key from Bearer token', () => { + describe("API Key Extraction", () => { + it("should extract API key from Bearer token", () => { const mockRequest = { headers: { - authorization: 'Bearer sk_prod_abcdef123456', + authorization: "Bearer sk_prod_abcdef123456", }, query: {}, }; const result = (interceptor as any).extractApiKey(mockRequest); - if (result !== 'sk_prod_abcdef123456') throw new Error('API key mismatch'); + if (result !== "sk_prod_abcdef123456") + throw new Error("API key mismatch"); }); - it('should extract API key from X-API-Key header', () => { + it("should extract API key from X-API-Key header", () => { const mockRequest = { headers: { - 'x-api-key': 'sk_prod_xyz789', + "x-api-key": "sk_prod_xyz789", }, query: {}, }; const result = (interceptor as any).extractApiKey(mockRequest); - if (result !== 'sk_prod_xyz789') throw new Error('X-API-Key mismatch'); + if (result !== "sk_prod_xyz789") throw new Error("X-API-Key mismatch"); }); - it('should extract API key from query parameter', () => { + it("should extract API key from query parameter", () => { const mockRequest = { headers: {}, query: { - apiKey: 'sk_prod_query123', + apiKey: "sk_prod_query123", }, }; const result = (interceptor as any).extractApiKey(mockRequest); - if (result !== 'sk_prod_query123') throw new Error('Query param mismatch'); + if (result !== "sk_prod_query123") + throw new Error("Query param mismatch"); }); - it('should return null if no API key found', () => { + it("should return null if no API key found", () => { const mockRequest = { headers: {}, query: {}, }; const result = (interceptor as any).extractApiKey(mockRequest); - if (result !== null) throw new Error('Should be null'); + if (result !== null) throw new Error("Should be null"); }); - it('should prioritize Authorization header over others', () => { + it("should prioritize Authorization header over others", () => { const mockRequest = { headers: { - authorization: 'Bearer header_key', - 'x-api-key': 'header_api_key', + authorization: "Bearer header_key", + "x-api-key": "header_api_key", }, query: { - apiKey: 'query_key', + apiKey: "query_key", }, }; const result = (interceptor as any).extractApiKey(mockRequest); - if (result !== 'header_key') throw new Error('Header key mismatch'); + if (result !== "header_key") throw new Error("Header key mismatch"); }); }); - describe('URL Skip Patterns', () => { - it('should skip health check endpoints', () => { - if (!(interceptor as any).shouldSkipAudit('/health')) throw new Error('Should skip health'); - if (!(interceptor as any).shouldSkipAudit('/health/ready')) throw new Error('Should skip health/ready'); - if (!(interceptor as any).shouldSkipAudit('/health/live')) throw new Error('Should skip health/live'); + describe("URL Skip Patterns", () => { + it("should skip health check endpoints", () => { + if (!(interceptor as any).shouldSkipAudit("/health")) + throw new Error("Should skip health"); + if (!(interceptor as any).shouldSkipAudit("/health/ready")) + throw new Error("Should skip health/ready"); + if (!(interceptor as any).shouldSkipAudit("/health/live")) + throw new Error("Should skip health/live"); }); - it('should skip metrics endpoints', () => { - if (!(interceptor as any).shouldSkipAudit('/metrics')) throw new Error('Should skip metrics'); + it("should skip metrics endpoints", () => { + if (!(interceptor as any).shouldSkipAudit("/metrics")) + throw new Error("Should skip metrics"); }); - it('should skip swagger endpoints', () => { - if (!(interceptor as any).shouldSkipAudit('/swagger')) throw new Error('Should skip swagger'); - if (!(interceptor as any).shouldSkipAudit('/api-docs')) throw new Error('Should skip api-docs'); + it("should skip swagger endpoints", () => { + if (!(interceptor as any).shouldSkipAudit("/swagger")) + throw new Error("Should skip swagger"); + if (!(interceptor as any).shouldSkipAudit("/api-docs")) + throw new Error("Should skip api-docs"); }); - it('should not skip regular API endpoints', () => { - if ((interceptor as any).shouldSkipAudit('/scanner/scan')) throw new Error('Should not skip scanner'); - if ((interceptor as any).shouldSkipAudit('/analyzer/analyze')) throw new Error('Should not skip analyzer'); - if ((interceptor as any).shouldSkipAudit('/api/endpoint')) throw new Error('Should not skip api/endpoint'); + it("should not skip regular API endpoints", () => { + if ((interceptor as any).shouldSkipAudit("/scanner/scan")) + throw new Error("Should not skip scanner"); + if ((interceptor as any).shouldSkipAudit("/analyzer/analyze")) + throw new Error("Should not skip analyzer"); + if ((interceptor as any).shouldSkipAudit("/api/endpoint")) + throw new Error("Should not skip api/endpoint"); }); }); }); diff --git a/apps/api-service/src/audit/interceptors/audit.interceptor.ts b/apps/api-service/src/audit/interceptors/audit.interceptor.ts index 7b61e23..0696874 100644 --- a/apps/api-service/src/audit/interceptors/audit.interceptor.ts +++ b/apps/api-service/src/audit/interceptors/audit.interceptor.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@nestjs/common'; -import { AuditLogService } from '../services/audit-log.service'; +import { Injectable } from "@nestjs/common"; +import { AuditLogService } from "../services/audit-log.service"; @Injectable() export class AuditInterceptor { @@ -25,7 +25,7 @@ export class AuditInterceptor { const statusCode = response.statusCode || 200; this.auditLogService.emitApiRequest( - apiKey || 'anonymous', + apiKey || "anonymous", url, method, statusCode, @@ -39,7 +39,7 @@ export class AuditInterceptor { const statusCode = error.status || 500; this.auditLogService.emitApiRequest( - apiKey || 'anonymous', + apiKey || "anonymous", url, method, statusCode, @@ -55,13 +55,13 @@ export class AuditInterceptor { private extractApiKey(request: any): string | null { // Check Authorization header const authHeader = request.headers.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { + if (authHeader && authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } // Check X-API-Key header - if (request.headers['x-api-key']) { - return request.headers['x-api-key']; + if (request.headers["x-api-key"]) { + return request.headers["x-api-key"]; } // Check query parameters @@ -74,12 +74,12 @@ export class AuditInterceptor { private shouldSkipAudit(url: string): boolean { const excludePatterns = [ - '/health', - '/health/ready', - '/health/live', - '/metrics', - '/swagger', - '/api-docs', + "/health", + "/health/ready", + "/health/live", + "/metrics", + "/swagger", + "/api-docs", ]; return excludePatterns.some((pattern) => url.includes(pattern)); diff --git a/apps/api-service/src/audit/interceptors/index.ts b/apps/api-service/src/audit/interceptors/index.ts index 51c2ac0..6ffbb82 100644 --- a/apps/api-service/src/audit/interceptors/index.ts +++ b/apps/api-service/src/audit/interceptors/index.ts @@ -1 +1 @@ -export { AuditInterceptor } from './audit.interceptor'; +export { AuditInterceptor } from "./audit.interceptor"; diff --git a/apps/api-service/src/audit/services/__tests__/api-key.service.spec.ts b/apps/api-service/src/audit/services/__tests__/api-key.service.spec.ts index e29804a..c311278 100644 --- a/apps/api-service/src/audit/services/__tests__/api-key.service.spec.ts +++ b/apps/api-service/src/audit/services/__tests__/api-key.service.spec.ts @@ -1,13 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { ConfigService } from '@nestjs/config'; -import { ApiKeyService } from '../api-key.service'; -import { ApiKeyRepository } from '../api-key.repository'; -import { AuditLogService } from '../audit-log.service'; -import { ApiKey, ApiKeyStatus } from '../../entities/api-key.entity'; -import { EventType } from '../../entities/audit-log.entity'; - -describe('ApiKeyService', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ConfigService } from "@nestjs/config"; +import { ApiKeyService } from "../api-key.service"; +import { ApiKeyRepository } from "../api-key.repository"; +import { AuditLogService } from "../audit-log.service"; +import { ApiKey, ApiKeyStatus } from "../../entities/api-key.entity"; +import { EventType } from "../../entities/audit-log.entity"; + +describe("ApiKeyService", () => { let service: ApiKeyService; beforeEach(async () => { @@ -34,9 +34,9 @@ describe('ApiKeyService', () => { const mockConfigService = { get: (key: string, defaultValue: any) => { const config: Record = { - 'API_KEY_DEFAULT_EXPIRY_DAYS': 90, - 'API_KEY_ROTATION_GRACE_PERIOD_HOURS': 24, - 'API_KEY_PREFIX': 'gg', + API_KEY_DEFAULT_EXPIRY_DAYS: 90, + API_KEY_ROTATION_GRACE_PERIOD_HOURS: 24, + API_KEY_PREFIX: "gg", }; return config[key] || defaultValue; }, @@ -63,67 +63,86 @@ describe('ApiKeyService', () => { service = module.get(ApiKeyService); }); - it('should be defined', () => { - if (!service) throw new Error('Service not defined'); + it("should be defined", () => { + if (!service) throw new Error("Service not defined"); }); - it('should have createApiKey method', () => { - if (typeof service.createApiKey !== 'function') throw new Error('No createApiKey method'); + it("should have createApiKey method", () => { + if (typeof service.createApiKey !== "function") + throw new Error("No createApiKey method"); }); - it('should have validateApiKey method', () => { - if (typeof service.validateApiKey !== 'function') throw new Error('No validateApiKey method'); + it("should have validateApiKey method", () => { + if (typeof service.validateApiKey !== "function") + throw new Error("No validateApiKey method"); }); - it('should have getApiKeyStatus method', () => { - if (typeof service.getApiKeyStatus !== 'function') throw new Error('No getApiKeyStatus method'); + it("should have getApiKeyStatus method", () => { + if (typeof service.getApiKeyStatus !== "function") + throw new Error("No getApiKeyStatus method"); }); - it('should have listApiKeys method', () => { - if (typeof service.listApiKeys !== 'function') throw new Error('No listApiKeys method'); + it("should have listApiKeys method", () => { + if (typeof service.listApiKeys !== "function") + throw new Error("No listApiKeys method"); }); - it('should have rotateApiKey method', () => { - if (typeof service.rotateApiKey !== 'function') throw new Error('No rotateApiKey method'); + it("should have rotateApiKey method", () => { + if (typeof service.rotateApiKey !== "function") + throw new Error("No rotateApiKey method"); }); - it('should have revokeApiKey method', () => { - if (typeof service.revokeApiKey !== 'function') throw new Error('No revokeApiKey method'); + it("should have revokeApiKey method", () => { + if (typeof service.revokeApiKey !== "function") + throw new Error("No revokeApiKey method"); }); - it('should have processExpiredKeys method', () => { - if (typeof service.processExpiredKeys !== 'function') throw new Error('No processExpiredKeys method'); + it("should have processExpiredKeys method", () => { + if (typeof service.processExpiredKeys !== "function") + throw new Error("No processExpiredKeys method"); }); - it('should have hashApiKey method', () => { - if (typeof service.hashApiKey !== 'function') throw new Error('No hashApiKey method'); + it("should have hashApiKey method", () => { + if (typeof service.hashApiKey !== "function") + throw new Error("No hashApiKey method"); }); - it('should consistently hash API keys', () => { - const key = 'test-api-key'; + it("should consistently hash API keys", () => { + const key = "test-api-key"; const hash1 = service.hashApiKey(key); const hash2 = service.hashApiKey(key); - if (hash1 !== hash2) throw new Error('Hash is not consistent'); - if (hash1.length !== 64) throw new Error('SHA256 hash should be 64 characters'); + if (hash1 !== hash2) throw new Error("Hash is not consistent"); + if (hash1.length !== 64) + throw new Error("SHA256 hash should be 64 characters"); }); - it('should produce different hashes for different keys', () => { - const hash1 = service.hashApiKey('key1'); - const hash2 = service.hashApiKey('key2'); + it("should produce different hashes for different keys", () => { + const hash1 = service.hashApiKey("key1"); + const hash2 = service.hashApiKey("key2"); - if (hash1 === hash2) throw new Error('Different keys should produce different hashes'); + if (hash1 === hash2) + throw new Error("Different keys should produce different hashes"); }); - it('should support API key statuses', () => { - if (!ApiKeyStatus.ACTIVE || !ApiKeyStatus.ROTATED || !ApiKeyStatus.REVOKED || !ApiKeyStatus.EXPIRED) { - throw new Error('Missing API key statuses'); + it("should support API key statuses", () => { + if ( + !ApiKeyStatus.ACTIVE || + !ApiKeyStatus.ROTATED || + !ApiKeyStatus.REVOKED || + !ApiKeyStatus.EXPIRED + ) { + throw new Error("Missing API key statuses"); } }); - it('should support key lifecycle event types', () => { - if (!EventType.API_KEY_CREATED || !EventType.API_KEY_ROTATED || !EventType.API_KEY_REVOKED) { - throw new Error('Missing API key event types'); + it("should support key lifecycle event types", () => { + if ( + !EventType.API_KEY_CREATED || + !EventType.API_KEY_ROTATED || + !EventType.API_KEY_REVOKED + ) { + throw new Error("Missing API key event types"); } }); }); diff --git a/apps/api-service/src/audit/services/__tests__/audit-event-emitter.spec.ts b/apps/api-service/src/audit/services/__tests__/audit-event-emitter.spec.ts index 2cf486f..b83c0e0 100644 --- a/apps/api-service/src/audit/services/__tests__/audit-event-emitter.spec.ts +++ b/apps/api-service/src/audit/services/__tests__/audit-event-emitter.spec.ts @@ -1,36 +1,48 @@ -import { AuditEventEmitter } from '../audit-event-emitter'; -import { EventType, OutcomeStatus } from '../../entities'; +import { AuditEventEmitter } from "../audit-event-emitter"; +import { EventType, OutcomeStatus } from "../../entities"; -describe('AuditEventEmitter', () => { +describe("AuditEventEmitter", () => { let emitter: AuditEventEmitter; beforeEach(() => { emitter = new AuditEventEmitter(); }); - it('should be defined', () => { - if (!emitter) throw new Error('Emitter not defined'); + it("should be defined", () => { + if (!emitter) throw new Error("Emitter not defined"); }); - it('should have required methods', () => { - if (typeof emitter.onAuditEvent !== 'function' || - typeof emitter.emitApiKeyEvent !== 'function' || - typeof emitter.emitApiRequestEvent !== 'function' || - typeof emitter.emitGasTransactionEvent !== 'function') { - throw new Error('Missing required methods'); + it("should have required methods", () => { + if ( + typeof emitter.onAuditEvent !== "function" || + typeof emitter.emitApiKeyEvent !== "function" || + typeof emitter.emitApiRequestEvent !== "function" || + typeof emitter.emitGasTransactionEvent !== "function" + ) { + throw new Error("Missing required methods"); } }); - it('should support event types', () => { - if (!EventType.API_REQUEST || !EventType.API_KEY_CREATED || !EventType.API_KEY_ROTATED || - !EventType.GAS_TRANSACTION || !EventType.API_KEY_REVOKED || !EventType.GAS_SUBMISSION) { - throw new Error('Missing event types'); + it("should support event types", () => { + if ( + !EventType.API_REQUEST || + !EventType.API_KEY_CREATED || + !EventType.API_KEY_ROTATED || + !EventType.GAS_TRANSACTION || + !EventType.API_KEY_REVOKED || + !EventType.GAS_SUBMISSION + ) { + throw new Error("Missing event types"); } }); - it('should support outcome statuses', () => { - if (!OutcomeStatus.SUCCESS || !OutcomeStatus.FAILURE || !OutcomeStatus.WARNING) { - throw new Error('Missing outcome statuses'); + it("should support outcome statuses", () => { + if ( + !OutcomeStatus.SUCCESS || + !OutcomeStatus.FAILURE || + !OutcomeStatus.WARNING + ) { + throw new Error("Missing outcome statuses"); } }); }); diff --git a/apps/api-service/src/audit/services/__tests__/audit-log.service.spec.ts b/apps/api-service/src/audit/services/__tests__/audit-log.service.spec.ts index f2beb4d..ab95302 100644 --- a/apps/api-service/src/audit/services/__tests__/audit-log.service.spec.ts +++ b/apps/api-service/src/audit/services/__tests__/audit-log.service.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuditLogService } from '../audit-log.service'; -import { AuditLogRepository } from '../audit-log.repository'; -import { AuditEventEmitter } from '../audit-event-emitter'; -import { AuditLog, EventType, OutcomeStatus } from '../../entities'; - -describe('AuditLogService', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { AuditLogService } from "../audit-log.service"; +import { AuditLogRepository } from "../audit-log.repository"; +import { AuditEventEmitter } from "../audit-event-emitter"; +import { AuditLog, EventType, OutcomeStatus } from "../../entities"; + +describe("AuditLogService", () => { let service: AuditLogService; beforeEach(async () => { @@ -32,27 +32,37 @@ describe('AuditLogService', () => { service = module.get(AuditLogService); }); - it('should be defined', () => { - if (!service) throw new Error('Service not defined'); + it("should be defined", () => { + if (!service) throw new Error("Service not defined"); }); - it('should have queryLogs method', () => { - if (typeof service.queryLogs !== 'function') throw new Error('No queryLogs method'); + it("should have queryLogs method", () => { + if (typeof service.queryLogs !== "function") + throw new Error("No queryLogs method"); }); - it('should have exportLogs method', () => { - if (typeof service.exportLogs !== 'function') throw new Error('No exportLogs method'); + it("should have exportLogs method", () => { + if (typeof service.exportLogs !== "function") + throw new Error("No exportLogs method"); }); - it('should support event types', () => { - if (!EventType.API_REQUEST || !EventType.API_KEY_CREATED || !EventType.GAS_TRANSACTION) { - throw new Error('Missing event types'); + it("should support event types", () => { + if ( + !EventType.API_REQUEST || + !EventType.API_KEY_CREATED || + !EventType.GAS_TRANSACTION + ) { + throw new Error("Missing event types"); } }); - it('should support outcome statuses', () => { - if (!OutcomeStatus.SUCCESS || !OutcomeStatus.FAILURE || !OutcomeStatus.WARNING) { - throw new Error('Missing outcome statuses'); + it("should support outcome statuses", () => { + if ( + !OutcomeStatus.SUCCESS || + !OutcomeStatus.FAILURE || + !OutcomeStatus.WARNING + ) { + throw new Error("Missing outcome statuses"); } }); }); diff --git a/apps/api-service/src/audit/services/api-key-expiration.service.ts b/apps/api-service/src/audit/services/api-key-expiration.service.ts index c65b491..057898f 100644 --- a/apps/api-service/src/audit/services/api-key-expiration.service.ts +++ b/apps/api-service/src/audit/services/api-key-expiration.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { ApiKeyService } from './api-key.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { ApiKeyService } from "./api-key.service"; /** * Service to handle scheduled API key expiration tasks @@ -15,15 +15,15 @@ export class ApiKeyExpirationService { * Daily job to process expired API keys * Runs at midnight every day */ - @Cron('0 0 * * *') // Midnight daily + @Cron("0 0 * * *") // Midnight daily async handleExpiredKeys(): Promise { - this.logger.log('Starting daily expired API key cleanup...'); - + this.logger.log("Starting daily expired API key cleanup..."); + try { const expiredCount = await this.apiKeyService.processExpiredKeys(); this.logger.log(`Processed ${expiredCount} expired API keys`); } catch (error) { - this.logger.error('Failed to process expired API keys', error); + this.logger.error("Failed to process expired API keys", error); } } @@ -31,15 +31,17 @@ export class ApiKeyExpirationService { * Daily job to clean up rotated keys that have passed their grace period * Runs at 1 AM every day */ - @Cron('0 1 * * *') // 1 AM daily + @Cron("0 1 * * *") // 1 AM daily async cleanupRotatedKeys(): Promise { - this.logger.log('Starting rotated API key cleanup...'); - + this.logger.log("Starting rotated API key cleanup..."); + try { const revokedCount = await this.apiKeyService.cleanupRotatedKeys(); - this.logger.log(`Revoked ${revokedCount} rotated API keys past grace period`); + this.logger.log( + `Revoked ${revokedCount} rotated API keys past grace period`, + ); } catch (error) { - this.logger.error('Failed to cleanup rotated API keys', error); + this.logger.error("Failed to cleanup rotated API keys", error); } } @@ -48,26 +50,30 @@ export class ApiKeyExpirationService { * Could be used for sending notifications * Runs every Monday at 9 AM */ - @Cron('0 9 * * 1') // Monday at 9 AM + @Cron("0 9 * * 1") // Monday at 9 AM async checkExpiringKeys(): Promise { - this.logger.log('Checking for API keys expiring soon...'); - + this.logger.log("Checking for API keys expiring soon..."); + try { const expiringKeys = await this.apiKeyService.getKeysExpiringSoon(7); - + if (expiringKeys.length > 0) { - this.logger.log(`Found ${expiringKeys.length} API keys expiring within 7 days`); - + this.logger.log( + `Found ${expiringKeys.length} API keys expiring within 7 days`, + ); + // Log details for each expiring key (could integrate with notification service) for (const key of expiringKeys) { - this.logger.debug(`Key ${key.id} for merchant ${key.merchantId} expires at ${key.expiresAt}`); + this.logger.debug( + `Key ${key.id} for merchant ${key.merchantId} expires at ${key.expiresAt}`, + ); } - + // TODO: Integrate with notification service to alert merchants // await this.notificationService.sendExpiryReminders(expiringKeys); } } catch (error) { - this.logger.error('Failed to check expiring API keys', error); + this.logger.error("Failed to check expiring API keys", error); } } } diff --git a/apps/api-service/src/audit/services/api-key.repository.ts b/apps/api-service/src/audit/services/api-key.repository.ts index cde4b8d..229e3fa 100644 --- a/apps/api-service/src/audit/services/api-key.repository.ts +++ b/apps/api-service/src/audit/services/api-key.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { ApiKey, ApiKeyStatus } from '../entities/api-key.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { ApiKey, ApiKeyStatus } from "../entities/api-key.entity"; @EntityRepository(ApiKey) export class ApiKeyRepository extends Repository { @@ -34,16 +34,16 @@ export class ApiKeyRepository extends Repository { offset: number = 0, status?: ApiKeyStatus, ): Promise<{ data: ApiKey[]; total: number }> { - const query = this.createQueryBuilder('apiKey') - .where('apiKey.merchantId = :merchantId', { merchantId }); + const query = this.createQueryBuilder("apiKey").where( + "apiKey.merchantId = :merchantId", + { merchantId }, + ); if (status) { - query.andWhere('apiKey.status = :status', { status }); + query.andWhere("apiKey.status = :status", { status }); } - query.orderBy('apiKey.createdAt', 'DESC') - .skip(offset) - .take(limit); + query.orderBy("apiKey.createdAt", "DESC").skip(offset).take(limit); const data = await query.getMany(); const total = data.length; @@ -113,9 +113,9 @@ export class ApiKeyRepository extends Repository { */ async findExpiredKeys(): Promise { const now = new Date(); - return this.createQueryBuilder('apiKey') - .where('apiKey.status = :status', { status: ApiKeyStatus.ACTIVE }) - .andWhere('apiKey.expiresAt < :now', { now }) + return this.createQueryBuilder("apiKey") + .where("apiKey.status = :status", { status: ApiKeyStatus.ACTIVE }) + .andWhere("apiKey.expiresAt < :now", { now }) .getMany(); } @@ -128,9 +128,9 @@ export class ApiKeyRepository extends Repository { const now = new Date(); - return this.createQueryBuilder('apiKey') - .where('apiKey.status = :status', { status: ApiKeyStatus.ACTIVE }) - .andWhere('apiKey.expiresAt BETWEEN :now AND :futureDate', { + return this.createQueryBuilder("apiKey") + .where("apiKey.status = :status", { status: ApiKeyStatus.ACTIVE }) + .andWhere("apiKey.expiresAt BETWEEN :now AND :futureDate", { now, futureDate, }) @@ -144,9 +144,9 @@ export class ApiKeyRepository extends Repository { const cutoffDate = new Date(); cutoffDate.setHours(cutoffDate.getHours() - gracePeriodHours); - return this.createQueryBuilder('apiKey') - .where('apiKey.status = :status', { status: ApiKeyStatus.ROTATED }) - .andWhere('apiKey.updatedAt < :cutoffDate', { cutoffDate }) + return this.createQueryBuilder("apiKey") + .where("apiKey.status = :status", { status: ApiKeyStatus.ROTATED }) + .andWhere("apiKey.updatedAt < :cutoffDate", { cutoffDate }) .getMany(); } diff --git a/apps/api-service/src/audit/services/api-key.service.ts b/apps/api-service/src/audit/services/api-key.service.ts index 1b9b149..8be52a6 100644 --- a/apps/api-service/src/audit/services/api-key.service.ts +++ b/apps/api-service/src/audit/services/api-key.service.ts @@ -1,24 +1,32 @@ -import { Injectable, UnauthorizedException, NotFoundException, ForbiddenException, HttpException, HttpStatus } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as crypto from 'crypto'; -import { ApiKeyRepository } from './api-key.repository'; -import { AuditLogService } from './audit-log.service'; -import { ApiKey, ApiKeyStatus } from '../entities/api-key.entity'; -import { EventType } from '../entities/audit-log.entity'; +import { + Injectable, + UnauthorizedException, + NotFoundException, + ForbiddenException, + HttpException, + HttpStatus, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as crypto from "crypto"; +import { ApiKeyRepository } from "./api-key.repository"; +import { AuditLogService } from "./audit-log.service"; +import { ApiKey, ApiKeyStatus } from "../entities/api-key.entity"; +import { EventType } from "../entities/audit-log.entity"; import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyStatusDto, ApiKeyListResponseDto, ApiKeyRotationResponseDto, -} from '../dto/api-key.dto'; +} from "../dto/api-key.dto"; export class ApiKeyExpiredException extends HttpException { constructor(expiredAt: Date, keyId: string) { super( { - error: 'APIKeyExpired', - message: 'This API key has expired. Please rotate or request a new key.', + error: "APIKeyExpired", + message: + "This API key has expired. Please rotate or request a new key.", expiredAt: expiredAt.toISOString(), keyId, }, @@ -31,8 +39,8 @@ export class ApiKeyRevokedException extends HttpException { constructor(keyId: string) { super( { - error: 'APIKeyRevoked', - message: 'This API key has been revoked.', + error: "APIKeyRevoked", + message: "This API key has been revoked.", keyId, }, HttpStatus.UNAUTHORIZED, @@ -51,18 +59,24 @@ export class ApiKeyService { private readonly auditLogService: AuditLogService, private readonly configService: ConfigService, ) { - this.defaultExpiryDays = this.configService.get('API_KEY_DEFAULT_EXPIRY_DAYS', 90); - this.rotationGracePeriodHours = this.configService.get('API_KEY_ROTATION_GRACE_PERIOD_HOURS', 24); - this.keyPrefix = this.configService.get('API_KEY_PREFIX', 'gg'); + this.defaultExpiryDays = this.configService.get( + "API_KEY_DEFAULT_EXPIRY_DAYS", + 90, + ); + this.rotationGracePeriodHours = this.configService.get( + "API_KEY_ROTATION_GRACE_PERIOD_HOURS", + 24, + ); + this.keyPrefix = this.configService.get("API_KEY_PREFIX", "gg"); } /** * Generate a cryptographically secure API key */ private generateApiKey(): { rawKey: string; keyHash: string } { - const randomPart = crypto.randomBytes(32).toString('base64url'); + const randomPart = crypto.randomBytes(32).toString("base64url"); const rawKey = `${this.keyPrefix}_${randomPart}`; - const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); + const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex"); return { rawKey, keyHash }; } @@ -70,7 +84,7 @@ export class ApiKeyService { * Hash an existing API key */ hashApiKey(rawKey: string): string { - return crypto.createHash('sha256').update(rawKey).digest('hex'); + return crypto.createHash("sha256").update(rawKey).digest("hex"); } /** @@ -117,17 +131,21 @@ export class ApiKeyService { keyHash, status: ApiKeyStatus.ACTIVE, expiresAt, - role: createDto.role || 'user', + role: createDto.role || "user", requestCount: 0, }); // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_CREATED, merchantId, { - keyId: apiKey.id, - name: apiKey.name, - role: apiKey.role, - expiresAt: apiKey.expiresAt, - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_CREATED, + merchantId, + { + keyId: apiKey.id, + name: apiKey.name, + role: apiKey.role, + expiresAt: apiKey.expiresAt, + }, + ); return { id: apiKey.id, @@ -152,20 +170,24 @@ export class ApiKeyService { const apiKey = await this.apiKeyRepository.findActiveByKeyHash(keyHash); if (!apiKey) { - throw new UnauthorizedException('Invalid API key'); + throw new UnauthorizedException("Invalid API key"); } // Check if expired if (this.isExpired(apiKey)) { // Mark as expired in database await this.apiKeyRepository.updateStatus(apiKey.id, ApiKeyStatus.EXPIRED); - + // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_REVOKED, apiKey.merchantId, { - keyId: apiKey.id, - reason: 'expired', - expiredAt: apiKey.expiresAt, - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_REVOKED, + apiKey.merchantId, + { + keyId: apiKey.id, + reason: "expired", + expiredAt: apiKey.expiresAt, + }, + ); throw new ApiKeyExpiredException(apiKey.expiresAt, apiKey.id); } @@ -186,11 +208,11 @@ export class ApiKeyService { const apiKey = await this.apiKeyRepository.findById(keyId); if (!apiKey) { - throw new NotFoundException('API key not found'); + throw new NotFoundException("API key not found"); } if (apiKey.merchantId !== merchantId) { - throw new ForbiddenException('You do not have access to this API key'); + throw new ForbiddenException("You do not have access to this API key"); } const isExpired = this.isExpired(apiKey); @@ -267,15 +289,15 @@ export class ApiKeyService { const oldKey = await this.apiKeyRepository.findById(keyId); if (!oldKey) { - throw new NotFoundException('API key not found'); + throw new NotFoundException("API key not found"); } if (oldKey.merchantId !== merchantId) { - throw new ForbiddenException('You do not have access to this API key'); + throw new ForbiddenException("You do not have access to this API key"); } if (oldKey.status === ApiKeyStatus.REVOKED) { - throw new ForbiddenException('Cannot rotate a revoked API key'); + throw new ForbiddenException("Cannot rotate a revoked API key"); } // Generate new key @@ -305,12 +327,16 @@ export class ApiKeyService { ); // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_ROTATED, merchantId, { - oldKeyId: oldKey.id, - newKeyId: newKey.id, - reason: reason || 'user-initiated', - gracePeriodEndsAt: oldKeyGracePeriodEndsAt, - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_ROTATED, + merchantId, + { + oldKeyId: oldKey.id, + newKeyId: newKey.id, + reason: reason || "user-initiated", + gracePeriodEndsAt: oldKeyGracePeriodEndsAt, + }, + ); return { id: newKey.id, @@ -333,24 +359,28 @@ export class ApiKeyService { const apiKey = await this.apiKeyRepository.findById(keyId); if (!apiKey) { - throw new NotFoundException('API key not found'); + throw new NotFoundException("API key not found"); } if (apiKey.merchantId !== merchantId) { - throw new ForbiddenException('You do not have access to this API key'); + throw new ForbiddenException("You do not have access to this API key"); } if (apiKey.status === ApiKeyStatus.REVOKED) { - throw new ForbiddenException('API key is already revoked'); + throw new ForbiddenException("API key is already revoked"); } await this.apiKeyRepository.updateStatus(keyId, ApiKeyStatus.REVOKED); // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_REVOKED, merchantId, { - revokedKeyId: keyId, - reason: reason || 'user-initiated', - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_REVOKED, + merchantId, + { + revokedKeyId: keyId, + reason: reason || "user-initiated", + }, + ); } /** @@ -363,11 +393,15 @@ export class ApiKeyService { await this.apiKeyRepository.updateStatus(key.id, ApiKeyStatus.EXPIRED); // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_REVOKED, key.merchantId, { - keyId: key.id, - reason: 'expired', - expiredAt: key.expiresAt, - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_REVOKED, + key.merchantId, + { + keyId: key.id, + reason: "expired", + expiredAt: key.expiresAt, + }, + ); } return expiredKeys.length; @@ -385,11 +419,15 @@ export class ApiKeyService { await this.apiKeyRepository.updateStatus(key.id, ApiKeyStatus.REVOKED); // Emit audit event - this.auditLogService.emitApiKeyEvent(EventType.API_KEY_REVOKED, key.merchantId, { - keyId: key.id, - reason: 'grace-period-ended', - rotatedFromId: key.rotatedFromId, - }); + this.auditLogService.emitApiKeyEvent( + EventType.API_KEY_REVOKED, + key.merchantId, + { + keyId: key.id, + reason: "grace-period-ended", + rotatedFromId: key.rotatedFromId, + }, + ); } return keysToRevoke.length; diff --git a/apps/api-service/src/audit/services/audit-event-emitter.ts b/apps/api-service/src/audit/services/audit-event-emitter.ts index a79ba5c..2a4e5bb 100644 --- a/apps/api-service/src/audit/services/audit-event-emitter.ts +++ b/apps/api-service/src/audit/services/audit-event-emitter.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@nestjs/common'; -import { AuditLog, EventType, OutcomeStatus } from '../entities'; +import { Injectable } from "@nestjs/common"; +import { AuditLog, EventType, OutcomeStatus } from "../entities"; interface AuditEventListener { (payload: AuditEventPayload): void; @@ -25,7 +25,7 @@ export class AuditEventEmitter { private listeners: AuditEventListener[] = []; emitAuditEvent(payload: AuditEventPayload): void { - this.listeners.forEach(listener => listener(payload)); + this.listeners.forEach((listener) => listener(payload)); } onAuditEvent(callback: AuditEventListener): void { @@ -33,7 +33,10 @@ export class AuditEventEmitter { } emitApiKeyEvent( - eventType: EventType.API_KEY_CREATED | EventType.API_KEY_ROTATED | EventType.API_KEY_REVOKED, + eventType: + | EventType.API_KEY_CREATED + | EventType.API_KEY_ROTATED + | EventType.API_KEY_REVOKED, merchantId: string, details: Record, ): void { @@ -87,7 +90,8 @@ export class AuditEventEmitter { ipAddress, responseDuration, errorMessage, - outcome: responseStatus >= 400 ? OutcomeStatus.FAILURE : OutcomeStatus.SUCCESS, + outcome: + responseStatus >= 400 ? OutcomeStatus.FAILURE : OutcomeStatus.SUCCESS, details: {}, }); } @@ -119,7 +123,7 @@ export class AuditEventEmitter { emitRoleChangeEvent( adminUser: string, targetUser: string, - action: 'grant' | 'revoke' | 'update', + action: "grant" | "revoke" | "update", role: string, previousRole?: string, ): void { diff --git a/apps/api-service/src/audit/services/audit-log.repository.ts b/apps/api-service/src/audit/services/audit-log.repository.ts index f568d15..d062254 100644 --- a/apps/api-service/src/audit/services/audit-log.repository.ts +++ b/apps/api-service/src/audit/services/audit-log.repository.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AuditLog, EventType, OutcomeStatus } from '../entities'; -import { - AuditLogFilterDto, - CreateAuditLogDto, +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AuditLog, EventType, OutcomeStatus } from "../entities"; +import { + AuditLogFilterDto, + CreateAuditLogDto, AuditLogResponseDto, AuditLogsPageDto, -} from '../dto/audit-log.dto'; -import { AuditEventEmitter, AuditEventPayload } from './audit-event-emitter'; +} from "../dto/audit-log.dto"; +import { AuditEventEmitter, AuditEventPayload } from "./audit-event-emitter"; @Injectable() export class AuditLogRepository { @@ -43,43 +43,47 @@ export class AuditLogRepository { } async findWithFilters(filters: AuditLogFilterDto): Promise { - const query = this.auditLogRepo.createQueryBuilder('audit'); + const query = this.auditLogRepo.createQueryBuilder("audit"); if (filters.eventType) { - query.andWhere('audit.eventType = :eventType', { eventType: filters.eventType }); + query.andWhere("audit.eventType = :eventType", { + eventType: filters.eventType, + }); } if (filters.user) { - query.andWhere('audit.user = :user', { user: filters.user }); + query.andWhere("audit.user = :user", { user: filters.user }); } if (filters.apiKey) { - query.andWhere('audit.apiKey = :apiKey', { apiKey: filters.apiKey }); + query.andWhere("audit.apiKey = :apiKey", { apiKey: filters.apiKey }); } if (filters.chainId) { - query.andWhere('audit.chainId = :chainId', { chainId: filters.chainId }); + query.andWhere("audit.chainId = :chainId", { chainId: filters.chainId }); } if (filters.outcome) { - query.andWhere('audit.outcome = :outcome', { outcome: filters.outcome }); + query.andWhere("audit.outcome = :outcome", { outcome: filters.outcome }); } if (filters.from || filters.to) { if (filters.from && filters.to) { - query.andWhere('audit.timestamp BETWEEN :from AND :to', { + query.andWhere("audit.timestamp BETWEEN :from AND :to", { from: new Date(filters.from), to: new Date(filters.to), }); } else if (filters.from) { - query.andWhere('audit.timestamp >= :from', { from: new Date(filters.from) }); + query.andWhere("audit.timestamp >= :from", { + from: new Date(filters.from), + }); } else if (filters.to) { - query.andWhere('audit.timestamp <= :to', { to: new Date(filters.to) }); + query.andWhere("audit.timestamp <= :to", { to: new Date(filters.to) }); } } - const sortBy = filters.sortBy || 'timestamp'; - const sortOrder = filters.sortOrder || 'DESC'; + const sortBy = filters.sortBy || "timestamp"; + const sortOrder = filters.sortOrder || "DESC"; query.orderBy(`audit.${sortBy}`, sortOrder as any); query.limit(filters.limit || 50); @@ -96,10 +100,13 @@ export class AuditLogRepository { }; } - async findByEventType(eventType: EventType, limit = 100): Promise { + async findByEventType( + eventType: EventType, + limit = 100, + ): Promise { return this.auditLogRepo.find({ where: { eventType }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, }); } @@ -107,7 +114,7 @@ export class AuditLogRepository { async findByUser(user: string, limit = 100): Promise { return this.auditLogRepo.find({ where: { user }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, }); } @@ -115,7 +122,7 @@ export class AuditLogRepository { async findByApiKey(apiKey: string, limit = 100): Promise { return this.auditLogRepo.find({ where: { apiKey }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, }); } @@ -123,17 +130,21 @@ export class AuditLogRepository { async findByChain(chainId: number, limit = 100): Promise { return this.auditLogRepo.find({ where: { chainId }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, }); } - async findByDateRange(from: Date, to: Date, limit = 1000): Promise { + async findByDateRange( + from: Date, + to: Date, + limit = 1000, + ): Promise { return this.auditLogRepo - .createQueryBuilder('audit') - .where('audit.timestamp >= :from', { from }) - .andWhere('audit.timestamp <= :to', { to }) - .orderBy('audit.timestamp', 'DESC') + .createQueryBuilder("audit") + .where("audit.timestamp >= :from", { from }) + .andWhere("audit.timestamp <= :to", { to }) + .orderBy("audit.timestamp", "DESC") .take(limit) .getMany(); } @@ -145,7 +156,7 @@ export class AuditLogRepository { const result = await this.auditLogRepo .createQueryBuilder() .delete() - .where('timestamp < :cutoff', { cutoff: cutoffDate }) + .where("timestamp < :cutoff", { cutoff: cutoffDate }) .execute(); return result.affected || 0; @@ -153,7 +164,7 @@ export class AuditLogRepository { private generateIntegrity(_dto: CreateAuditLogDto): string { // Simple hash placeholder - crypto not available in this build - return 'hash-' + Date.now().toString(36); + return "hash-" + Date.now().toString(36); } private mapToResponse(auditLog: AuditLog): AuditLogResponseDto { diff --git a/apps/api-service/src/audit/services/audit-log.service.ts b/apps/api-service/src/audit/services/audit-log.service.ts index 6ea3742..f36f3f5 100644 --- a/apps/api-service/src/audit/services/audit-log.service.ts +++ b/apps/api-service/src/audit/services/audit-log.service.ts @@ -1,12 +1,12 @@ -import { Injectable } from '@nestjs/common'; -import { EventType, OutcomeStatus } from '../entities'; -import { AuditLogRepository } from './audit-log.repository'; -import { AuditEventEmitter, AuditEventPayload } from './audit-event-emitter'; -import { - AuditLogFilterDto, - CreateAuditLogDto, +import { Injectable } from "@nestjs/common"; +import { EventType, OutcomeStatus } from "../entities"; +import { AuditLogRepository } from "./audit-log.repository"; +import { AuditEventEmitter, AuditEventPayload } from "./audit-event-emitter"; +import { + AuditLogFilterDto, + CreateAuditLogDto, AuditLogsPageDto, -} from '../dto/audit-log.dto'; +} from "../dto/audit-log.dto"; @Injectable() export class AuditLogService { @@ -17,7 +17,7 @@ export class AuditLogService { // Listen to audit events and save them to database this.auditEventEmitter.onAuditEvent((payload) => { this.logEvent(payload).catch((error) => { - console.error('Failed to log audit event:', error); + console.error("Failed to log audit event:", error); }); }); } @@ -97,37 +97,37 @@ export class AuditLogService { * Export logs as CSV or JSON */ async exportLogs( - format: 'csv' | 'json', + format: "csv" | "json", filters?: AuditLogFilterDto, ): Promise { const response = await this.auditLogRepository.findWithFilters( filters || { limit: 10000, offset: 0 }, ); - if (format === 'json') { + if (format === "json") { return JSON.stringify(response.data, null, 2); } - if (format === 'csv') { + if (format === "csv") { // Simple CSV generation without external dependency const headers = [ - 'id', - 'eventType', - 'timestamp', - 'user', - 'apiKey', - 'chainId', - 'outcome', - 'endpoint', - 'httpMethod', - 'responseStatus', + "id", + "eventType", + "timestamp", + "user", + "apiKey", + "chainId", + "outcome", + "endpoint", + "httpMethod", + "responseStatus", ]; - - const rows = response.data.map(row => - headers.map(h => JSON.stringify((row as any)[h] || '')).join(',') + + const rows = response.data.map((row) => + headers.map((h) => JSON.stringify((row as any)[h] || "")).join(","), ); - - return [headers.join(','), ...rows].join('\n'); + + return [headers.join(","), ...rows].join("\n"); } throw new Error(`Unsupported format: ${format}`); @@ -167,7 +167,10 @@ export class AuditLogService { * Emit API key event */ emitApiKeyEvent( - eventType: EventType.API_KEY_CREATED | EventType.API_KEY_ROTATED | EventType.API_KEY_REVOKED, + eventType: + | EventType.API_KEY_CREATED + | EventType.API_KEY_ROTATED + | EventType.API_KEY_REVOKED, merchantId: string, details: Record, ): void { @@ -206,7 +209,12 @@ export class AuditLogService { changes: Record, target?: string, ): void { - this.auditEventEmitter.emitConfigUpdateEvent(adminUser, configType, changes, target); + this.auditEventEmitter.emitConfigUpdateEvent( + adminUser, + configType, + changes, + target, + ); } /** @@ -215,11 +223,17 @@ export class AuditLogService { emitRoleChange( adminUser: string, targetUser: string, - action: 'grant' | 'revoke' | 'update', + action: "grant" | "revoke" | "update", role: string, previousRole?: string, ): void { - this.auditEventEmitter.emitRoleChangeEvent(adminUser, targetUser, action, role, previousRole); + this.auditEventEmitter.emitRoleChangeEvent( + adminUser, + targetUser, + action, + role, + previousRole, + ); } /** @@ -252,6 +266,11 @@ export class AuditLogService { target: string, details?: Record, ): void { - this.auditEventEmitter.emitSystemAdminEvent(adminUser, action, target, details); + this.auditEventEmitter.emitSystemAdminEvent( + adminUser, + action, + target, + details, + ); } } diff --git a/apps/api-service/src/audit/services/index.ts b/apps/api-service/src/audit/services/index.ts index 03a39c9..b27421e 100644 --- a/apps/api-service/src/audit/services/index.ts +++ b/apps/api-service/src/audit/services/index.ts @@ -1,6 +1,13 @@ -export { AuditLogService } from './audit-log.service'; -export { AuditLogRepository } from './audit-log.repository'; -export { AuditEventEmitter, type AuditEventPayload } from './audit-event-emitter'; -export { ApiKeyService, ApiKeyExpiredException, ApiKeyRevokedException } from './api-key.service'; -export { ApiKeyRepository } from './api-key.repository'; -export { ApiKeyExpirationService } from './api-key-expiration.service'; +export { AuditLogService } from "./audit-log.service"; +export { AuditLogRepository } from "./audit-log.repository"; +export { + AuditEventEmitter, + type AuditEventPayload, +} from "./audit-event-emitter"; +export { + ApiKeyService, + ApiKeyExpiredException, + ApiKeyRevokedException, +} from "./api-key.service"; +export { ApiKeyRepository } from "./api-key.repository"; +export { ApiKeyExpirationService } from "./api-key-expiration.service"; diff --git a/apps/api-service/src/audit/services/pause-audit.service.ts b/apps/api-service/src/audit/services/pause-audit.service.ts index b26f101..1ff0848 100644 --- a/apps/api-service/src/audit/services/pause-audit.service.ts +++ b/apps/api-service/src/audit/services/pause-audit.service.ts @@ -3,16 +3,20 @@ import { ForbiddenException, ConflictException, Logger, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AuditLog, EventType, OutcomeStatus } from '../entities/audit-log.entity'; -import { UserRole } from '../../rbac/enums/role.enum'; -import { createHash } from 'crypto'; +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { + AuditLog, + EventType, + OutcomeStatus, +} from "../entities/audit-log.entity"; +import { UserRole } from "../../rbac/enums/role.enum"; +import { createHash } from "crypto"; export interface PauseRecord { id: string; - action: 'pause' | 'unpause'; + action: "pause" | "unpause"; triggeredBy: string; triggeredByRole: UserRole; reason: string; @@ -37,15 +41,22 @@ export class PauseAuditService { assertNotPaused(): void { if (this.paused) { - throw new ConflictException('System is currently paused. Critical operations are suspended.'); + throw new ConflictException( + "System is currently paused. Critical operations are suspended.", + ); } } - async pause(adminId: string, adminRole: UserRole, reason: string, durationMinutes?: number): Promise { + async pause( + adminId: string, + adminRole: UserRole, + reason: string, + durationMinutes?: number, + ): Promise { this.requireAdmin(adminRole); if (this.paused) { - throw new ConflictException('System is already paused'); + throw new ConflictException("System is already paused"); } const autoResumeAt = durationMinutes @@ -54,7 +65,13 @@ export class PauseAuditService { this.paused = true; - const record = this.buildRecord('pause', adminId, adminRole, reason, autoResumeAt); + const record = this.buildRecord( + "pause", + adminId, + adminRole, + reason, + autoResumeAt, + ); this.pauseLog.push(record); await this.writeAuditLog(record); @@ -68,16 +85,26 @@ export class PauseAuditService { return record; } - async unpause(adminId: string, adminRole: UserRole, reason: string): Promise { + async unpause( + adminId: string, + adminRole: UserRole, + reason: string, + ): Promise { this.requireAdmin(adminRole); if (!this.paused) { - throw new ConflictException('System is not currently paused'); + throw new ConflictException("System is not currently paused"); } this.paused = false; - const record = this.buildRecord('unpause', adminId, adminRole, reason, null); + const record = this.buildRecord( + "unpause", + adminId, + adminRole, + reason, + null, + ); this.pauseLog.push(record); await this.writeAuditLog(record); @@ -92,21 +119,23 @@ export class PauseAuditService { private requireAdmin(role: UserRole): void { if (role !== UserRole.ADMIN) { - throw new ForbiddenException('Only ADMIN role can control system pause state'); + throw new ForbiddenException( + "Only ADMIN role can control system pause state", + ); } } private buildRecord( - action: 'pause' | 'unpause', + action: "pause" | "unpause", triggeredBy: string, triggeredByRole: UserRole, reason: string, autoResumeAt: Date | null, ): PauseRecord { return { - id: createHash('sha256') + id: createHash("sha256") .update(`${action}:${triggeredBy}:${Date.now()}`) - .digest('hex') + .digest("hex") .slice(0, 16), action, triggeredBy, @@ -118,9 +147,9 @@ export class PauseAuditService { } private async writeAuditLog(record: PauseRecord): Promise { - const integrity = createHash('sha256') + const integrity = createHash("sha256") .update(JSON.stringify(record)) - .digest('hex'); + .digest("hex"); const log = this.auditRepo.create({ eventType: EventType.SYSTEM_ADMIN, @@ -139,12 +168,21 @@ export class PauseAuditService { await this.auditRepo.save(log); } - private async autoResume(adminId: string, originalReason: string): Promise { + private async autoResume( + adminId: string, + originalReason: string, + ): Promise { if (!this.paused) return; this.paused = false; - const record = this.buildRecord('unpause', 'system:auto-resume', UserRole.ADMIN, `Auto-resume after timed pause triggered by ${adminId}: ${originalReason}`, null); + const record = this.buildRecord( + "unpause", + "system:auto-resume", + UserRole.ADMIN, + `Auto-resume after timed pause triggered by ${adminId}: ${originalReason}`, + null, + ); this.pauseLog.push(record); await this.writeAuditLog(record); - this.logger.log('System auto-resumed after timed pause expired'); + this.logger.log("System auto-resumed after timed pause expired"); } } diff --git a/apps/api-service/src/auth/auth.module.ts b/apps/api-service/src/auth/auth.module.ts index 557c96f..c1351a2 100644 --- a/apps/api-service/src/auth/auth.module.ts +++ b/apps/api-service/src/auth/auth.module.ts @@ -1,13 +1,16 @@ -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '../database/entities/user.entity'; -import { AuthService } from './services/auth.service'; -import { JwtStrategy } from './strategies/jwt.strategy'; -import { ApiKeyAuthGuard, OptionalApiKeyAuthGuard } from './guards/api-key-auth.guard'; -import { ApiKeyService } from '../audit/services/api-key.service'; +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { User } from "../database/entities/user.entity"; +import { AuthService } from "./services/auth.service"; +import { JwtStrategy } from "./strategies/jwt.strategy"; +import { + ApiKeyAuthGuard, + OptionalApiKeyAuthGuard, +} from "./guards/api-key-auth.guard"; +import { ApiKeyService } from "../audit/services/api-key.service"; /** * Authentication Module @@ -15,13 +18,16 @@ import { ApiKeyService } from '../audit/services/api-key.service'; */ @Module({ imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), + PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET', 'your-secret-key-change-in-production'), + secret: configService.get( + "JWT_SECRET", + "your-secret-key-change-in-production", + ), signOptions: { - expiresIn: configService.get('JWT_EXPIRES_IN', '24h'), + expiresIn: configService.get("JWT_EXPIRES_IN", "24h"), }, }), inject: [ConfigService], diff --git a/apps/api-service/src/auth/guards/api-key-auth.guard.ts b/apps/api-service/src/auth/guards/api-key-auth.guard.ts index af1c7a6..27755fb 100644 --- a/apps/api-service/src/auth/guards/api-key-auth.guard.ts +++ b/apps/api-service/src/auth/guards/api-key-auth.guard.ts @@ -1,9 +1,6 @@ -import { - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { ApiKeyService } from '../../audit/services/api-key.service'; -import { ApiKey } from '../../audit/entities/api-key.entity'; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ApiKeyService } from "../../audit/services/api-key.service"; +import { ApiKey } from "../../audit/entities/api-key.entity"; /** * Interface for request with API key authentication @@ -27,31 +24,33 @@ export class ApiKeyAuthGuard { async canActivate(context: any): Promise { const request = context.switchToHttp().getRequest(); - + // Extract API key from request const rawApiKey = this.extractApiKey(request); - + if (!rawApiKey) { - throw new UnauthorizedException('API key is required. Provide it via Authorization header (Bearer), X-API-Key header, or apiKey query parameter.'); + throw new UnauthorizedException( + "API key is required. Provide it via Authorization header (Bearer), X-API-Key header, or apiKey query parameter.", + ); } try { // Validate the API key const apiKey = await this.apiKeyService.validateApiKey(rawApiKey); - + // Attach API key info to request for downstream use request.apiKey = apiKey; request.merchantId = apiKey.merchantId; - + return true; } catch (error) { // Re-throw API key specific errors if (error instanceof UnauthorizedException) { throw error; } - + // Generic error for other cases - throw new UnauthorizedException('Invalid API key'); + throw new UnauthorizedException("Invalid API key"); } } @@ -61,12 +60,12 @@ export class ApiKeyAuthGuard { private extractApiKey(request: any): string | null { // Check Authorization header (Bearer token) const authHeader = request.headers?.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { + if (authHeader && authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } // Check X-API-Key header - const apiKeyHeader = request.headers?.['x-api-key']; + const apiKeyHeader = request.headers?.["x-api-key"]; if (apiKeyHeader) { return apiKeyHeader; } @@ -90,10 +89,10 @@ export class OptionalApiKeyAuthGuard { async canActivate(context: any): Promise { const request = context.switchToHttp().getRequest(); - + // Extract API key from request const rawApiKey = this.extractApiKey(request); - + if (!rawApiKey) { // No API key provided, but that's okay for optional guard return true; @@ -102,11 +101,11 @@ export class OptionalApiKeyAuthGuard { try { // Validate the API key const apiKey = await this.apiKeyService.validateApiKey(rawApiKey); - + // Attach API key info to request for downstream use request.apiKey = apiKey; request.merchantId = apiKey.merchantId; - + return true; } catch (error) { // Even with invalid key, optional guard allows request through @@ -121,12 +120,12 @@ export class OptionalApiKeyAuthGuard { private extractApiKey(request: any): string | null { // Check Authorization header (Bearer token) const authHeader = request.headers?.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { + if (authHeader && authHeader.startsWith("Bearer ")) { return authHeader.slice(7); } // Check X-API-Key header - const apiKeyHeader = request.headers?.['x-api-key']; + const apiKeyHeader = request.headers?.["x-api-key"]; if (apiKeyHeader) { return apiKeyHeader; } diff --git a/apps/api-service/src/auth/guards/jwt-auth.guard.ts b/apps/api-service/src/auth/guards/jwt-auth.guard.ts index d4eba25..93e56fa 100644 --- a/apps/api-service/src/auth/guards/jwt-auth.guard.ts +++ b/apps/api-service/src/auth/guards/jwt-auth.guard.ts @@ -1,11 +1,15 @@ -import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { Observable } from 'rxjs'; +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { Observable } from "rxjs"; /** * JWT Authentication Guard * Protects routes by requiring a valid JWT token - * + * * Usage: * ```typescript * @UseGuards(JwtAuthGuard) @@ -14,7 +18,7 @@ import { Observable } from 'rxjs'; * ``` */ @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { +export class JwtAuthGuard extends AuthGuard("jwt") { canActivate( context: ExecutionContext, ): boolean | Promise | Observable { @@ -26,7 +30,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(err: Error, user: any, info: any) { // You can throw an exception based on either "info" or "err" arguments if (err || !user) { - throw err || new UnauthorizedException('Authentication required'); + throw err || new UnauthorizedException("Authentication required"); } return user; } @@ -35,7 +39,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { /** * Optional JWT Authentication Guard * Attaches user to request if token is present, but doesn't require it - * + * * Usage: * ```typescript * @UseGuards(OptionalJwtAuthGuard) @@ -44,7 +48,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { * ``` */ @Injectable() -export class OptionalJwtAuthGuard extends AuthGuard('jwt') { +export class OptionalJwtAuthGuard extends AuthGuard("jwt") { handleRequest(err: Error, user: any) { // Return user if authenticated, null otherwise (no error) return user || null; diff --git a/apps/api-service/src/auth/index.ts b/apps/api-service/src/auth/index.ts index 6517e39..49390b3 100644 --- a/apps/api-service/src/auth/index.ts +++ b/apps/api-service/src/auth/index.ts @@ -1,4 +1,4 @@ -export * from './auth.module'; -export * from './services/auth.service'; -export * from './guards/jwt-auth.guard'; -export * from './strategies/jwt.strategy'; +export * from "./auth.module"; +export * from "./services/auth.service"; +export * from "./guards/jwt-auth.guard"; +export * from "./strategies/jwt.strategy"; diff --git a/apps/api-service/src/auth/services/auth.service.ts b/apps/api-service/src/auth/services/auth.service.ts index 2ae76a8..0932e0a 100644 --- a/apps/api-service/src/auth/services/auth.service.ts +++ b/apps/api-service/src/auth/services/auth.service.ts @@ -1,10 +1,14 @@ -import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as bcrypt from 'bcrypt'; -import { User } from '../../database/entities/user.entity'; -import { UserRole } from '../../rbac/enums/role.enum'; +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import * as bcrypt from "bcrypt"; +import { User } from "../../database/entities/user.entity"; +import { UserRole } from "../../rbac/enums/role.enum"; /** * DTO for login request @@ -56,9 +60,9 @@ export class AuthService { */ async validateUser(email: string, password: string): Promise { const user = await this.userRepository - .createQueryBuilder('user') - .addSelect('user.passwordHash') - .where('user.email = :email', { email }) + .createQueryBuilder("user") + .addSelect("user.passwordHash") + .where("user.email = :email", { email }) .getOne(); if (!user) { @@ -67,7 +71,7 @@ export class AuthService { // Check if user can authenticate if (!user.canAuthenticate()) { - throw new UnauthorizedException('Account is not active or is locked'); + throw new UnauthorizedException("Account is not active or is locked"); } // Verify password @@ -89,7 +93,7 @@ export class AuthService { const user = await this.validateUser(dto.email, dto.password); if (!user) { - throw new UnauthorizedException('Invalid credentials'); + throw new UnauthorizedException("Invalid credentials"); } // Record successful login @@ -145,14 +149,20 @@ export class AuthService { /** * Verify password */ - async verifyPassword(password: string, hashedPassword: string): Promise { + async verifyPassword( + password: string, + hashedPassword: string, + ): Promise { return bcrypt.compare(password, hashedPassword); } /** * Record successful login */ - private async recordSuccessfulLogin(userId: string, ipAddress?: string): Promise { + private async recordSuccessfulLogin( + userId: string, + ipAddress?: string, + ): Promise { await this.userRepository.update(userId, { lastLoginAt: new Date(), lastLoginIp: ipAddress, @@ -186,8 +196,8 @@ export class AuthService { */ generatePasswordResetToken(userId: string): string { return this.jwtService.sign( - { sub: userId, type: 'password_reset' }, - { expiresIn: '1h' }, + { sub: userId, type: "password_reset" }, + { expiresIn: "1h" }, ); } @@ -198,7 +208,7 @@ export class AuthService { try { return this.jwtService.verify(token); } catch (error) { - throw new BadRequestException('Invalid or expired token'); + throw new BadRequestException("Invalid or expired token"); } } } diff --git a/apps/api-service/src/auth/strategies/jwt.strategy.ts b/apps/api-service/src/auth/strategies/jwt.strategy.ts index 17139b0..664bdc3 100644 --- a/apps/api-service/src/auth/strategies/jwt.strategy.ts +++ b/apps/api-service/src/auth/strategies/jwt.strategy.ts @@ -1,9 +1,9 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; -import { AuthService, JwtPayload } from '../services/auth.service'; -import { User } from '../../database/entities/user.entity'; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { ConfigService } from "@nestjs/config"; +import { AuthService, JwtPayload } from "../services/auth.service"; +import { User } from "../../database/entities/user.entity"; /** * JWT Strategy for Passport @@ -18,7 +18,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET', 'your-secret-key-change-in-production'), + secretOrKey: configService.get( + "JWT_SECRET", + "your-secret-key-change-in-production", + ), }); } @@ -30,7 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { const user = await this.authService.validatePayload(payload); if (!user) { - throw new UnauthorizedException('Invalid token or user not found'); + throw new UnauthorizedException("Invalid token or user not found"); } return user; diff --git a/apps/api-service/src/chain-reliability/chain-reliability.module.ts b/apps/api-service/src/chain-reliability/chain-reliability.module.ts index a762ef1..b5846c3 100644 --- a/apps/api-service/src/chain-reliability/chain-reliability.module.ts +++ b/apps/api-service/src/chain-reliability/chain-reliability.module.ts @@ -1,20 +1,16 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ChainReliabilityService } from './services/chain-reliability.service'; -import { ScheduledMetricsJob } from './scheduled-metrics.job'; -import { LeaderboardController } from './controllers/leaderboard.controller'; -import { ChainPerformanceMetric } from './entities/chain-performance-metric.entity'; -import { Chain } from '../database/entities/chain.entity'; -import { Transaction } from '../database/entities/transaction.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { ChainReliabilityService } from "./services/chain-reliability.service"; +import { ScheduledMetricsJob } from "./scheduled-metrics.job"; +import { LeaderboardController } from "./controllers/leaderboard.controller"; +import { ChainPerformanceMetric } from "./entities/chain-performance-metric.entity"; +import { Chain } from "../database/entities/chain.entity"; +import { Transaction } from "../database/entities/transaction.entity"; @Module({ imports: [ - TypeOrmModule.forFeature([ - ChainPerformanceMetric, - Chain, - Transaction, - ]), + TypeOrmModule.forFeature([ChainPerformanceMetric, Chain, Transaction]), ScheduleModule.forRoot(), ], controllers: [LeaderboardController], diff --git a/apps/api-service/src/chain-reliability/controllers/leaderboard.controller.ts b/apps/api-service/src/chain-reliability/controllers/leaderboard.controller.ts index aedba41..649cb81 100644 --- a/apps/api-service/src/chain-reliability/controllers/leaderboard.controller.ts +++ b/apps/api-service/src/chain-reliability/controllers/leaderboard.controller.ts @@ -1,34 +1,37 @@ -import { Controller, Get, Query, Param } from '@nestjs/common'; -import { ChainReliabilityService } from '../services/chain-reliability.service'; -import { MetricTimeWindow } from '../entities/chain-performance-metric.entity'; -import { LeaderboardEntry } from '../interfaces/chain-reliability.interface'; +import { Controller, Get, Query, Param } from "@nestjs/common"; +import { ChainReliabilityService } from "../services/chain-reliability.service"; +import { MetricTimeWindow } from "../entities/chain-performance-metric.entity"; +import { LeaderboardEntry } from "../interfaces/chain-reliability.interface"; -@Controller('api/v1/leaderboard') +@Controller("api/v1/leaderboard") export class LeaderboardController { - constructor(private readonly chainReliabilityService: ChainReliabilityService) {} + constructor( + private readonly chainReliabilityService: ChainReliabilityService, + ) {} /** * GET /api/v1/leaderboard * Get the chain reliability leaderboard * Public endpoint for developers and merchants to view chain rankings - * + * * Query params: * - timeWindow: Time window for metrics (daily, weekly, monthly) - default: daily * - limit: Maximum number of chains to return - default: 10 - * + * * @returns Ranked list of chains by reliability score */ @Get() async getLeaderboard( - @Query('timeWindow') timeWindow: string = 'daily', - @Query('limit') limit: string = '10', + @Query("timeWindow") timeWindow: string = "daily", + @Query("limit") limit: string = "10", ): Promise<{ success: boolean; data: LeaderboardEntry[]; timeWindow: string; generatedAt: string; }> { - const parsedTimeWindow = (timeWindow as MetricTimeWindow) || MetricTimeWindow.DAILY; + const parsedTimeWindow = + (timeWindow as MetricTimeWindow) || MetricTimeWindow.DAILY; const parsedLimit = parseInt(limit) || 10; const leaderboard = await this.chainReliabilityService.getLeaderboard({ @@ -47,16 +50,16 @@ export class LeaderboardController { /** * GET /api/v1/leaderboard/chain/:chainId * Get detailed performance data for a specific chain - * + * * @param chainId - The chain identifier * @param timeWindow - Time window for metrics (default: weekly) - * + * * @returns Detailed performance metrics for the chain */ - @Get('chain/:chainId') + @Get("chain/:chainId") async getChainPerformance( - @Param('chainId') chainId: string, - @Query('timeWindow') timeWindow: string = 'weekly', + @Param("chainId") chainId: string, + @Query("timeWindow") timeWindow: string = "weekly", ): Promise<{ success: boolean; chainId: string; @@ -64,12 +67,14 @@ export class LeaderboardController { data: any; generatedAt: string; }> { - const parsedTimeWindow = (timeWindow as MetricTimeWindow) || MetricTimeWindow.WEEKLY; + const parsedTimeWindow = + (timeWindow as MetricTimeWindow) || MetricTimeWindow.WEEKLY; - const history = await this.chainReliabilityService.getChainPerformanceHistory( - chainId, - parsedTimeWindow, - ); + const history = + await this.chainReliabilityService.getChainPerformanceHistory( + chainId, + parsedTimeWindow, + ); return { success: true, @@ -83,16 +88,16 @@ export class LeaderboardController { /** * GET /api/v1/leaderboard/compare * Get comparison data for multiple chains - * + * * @param chains - Comma-separated chain IDs * @param timeWindow - Time window for metrics (default: weekly) - * + * * @returns Comparison data for specified chains */ - @Get('compare') + @Get("compare") async compareChains( - @Query('chains') chains: string, - @Query('timeWindow') timeWindow: string = 'weekly', + @Query("chains") chains: string, + @Query("timeWindow") timeWindow: string = "weekly", ): Promise<{ success: boolean; chains: string[]; @@ -100,17 +105,19 @@ export class LeaderboardController { data: LeaderboardEntry[]; generatedAt: string; }> { - const chainIds = chains ? chains.split(',').map(c => c.trim()) : []; - const parsedTimeWindow = (timeWindow as MetricTimeWindow) || MetricTimeWindow.WEEKLY; + const chainIds = chains ? chains.split(",").map((c) => c.trim()) : []; + const parsedTimeWindow = + (timeWindow as MetricTimeWindow) || MetricTimeWindow.WEEKLY; const leaderboard = await this.chainReliabilityService.getLeaderboard({ timeWindow: parsedTimeWindow, limit: chainIds.length || 10, }); - const filteredData = chainIds.length > 0 - ? leaderboard.filter(entry => chainIds.includes(entry.chainId)) - : leaderboard; + const filteredData = + chainIds.length > 0 + ? leaderboard.filter((entry) => chainIds.includes(entry.chainId)) + : leaderboard; return { success: true, @@ -125,10 +132,10 @@ export class LeaderboardController { * GET /api/v1/leaderboard/health * Health check endpoint for the leaderboard service */ - @Get('health') + @Get("health") async health(): Promise<{ status: string; timestamp: string }> { return { - status: 'healthy', + status: "healthy", timestamp: new Date().toISOString(), }; } diff --git a/apps/api-service/src/chain-reliability/dto/leaderboard-query.dto.ts b/apps/api-service/src/chain-reliability/dto/leaderboard-query.dto.ts index b9d59b2..23c3ea1 100644 --- a/apps/api-service/src/chain-reliability/dto/leaderboard-query.dto.ts +++ b/apps/api-service/src/chain-reliability/dto/leaderboard-query.dto.ts @@ -1,5 +1,5 @@ -import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator'; -import { MetricTimeWindow } from '../entities/chain-performance-metric.entity'; +import { IsEnum, IsOptional, IsInt, Min, Max } from "class-validator"; +import { MetricTimeWindow } from "../entities/chain-performance-metric.entity"; export class LeaderboardQueryDto { @IsEnum(MetricTimeWindow) diff --git a/apps/api-service/src/chain-reliability/entities/chain-performance-metric.entity.ts b/apps/api-service/src/chain-reliability/entities/chain-performance-metric.entity.ts index 789b218..01707a5 100644 --- a/apps/api-service/src/chain-reliability/entities/chain-performance-metric.entity.ts +++ b/apps/api-service/src/chain-reliability/entities/chain-performance-metric.entity.ts @@ -1,77 +1,83 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, +} from "typeorm"; export enum MetricTimeWindow { - DAILY = 'daily', - WEEKLY = 'weekly', - MONTHLY = 'monthly', + DAILY = "daily", + WEEKLY = "weekly", + MONTHLY = "monthly", } -@Entity('chain_performance_metrics') +@Entity("chain_performance_metrics") export class ChainPerformanceMetric { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_cpm_chain_id') + @Column({ type: "varchar", length: 50 }) + @Index("idx_cpm_chain_id") chainId: string; - @Column({ type: 'varchar', length: 20 }) - @Index('idx_cpm_time_window') + @Column({ type: "varchar", length: 20 }) + @Index("idx_cpm_time_window") timeWindow: MetricTimeWindow; - @Column({ type: 'timestamp' }) - @Index('idx_cpm_recorded_at') + @Column({ type: "timestamp" }) + @Index("idx_cpm_recorded_at") recordedAt: Date; @CreateDateColumn() createdAt: Date; // Transaction metrics - @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) transactionSuccessRate: number; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) totalTransactions: number; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) successfulTransactions: number; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) failedTransactions: number; // Gas metrics - @Column({ type: 'decimal', precision: 20, scale: 8, nullable: true }) + @Column({ type: "decimal", precision: 20, scale: 8, nullable: true }) averageGasPrice: number; - @Column({ type: 'decimal', precision: 20, scale: 8, nullable: true }) + @Column({ type: "decimal", precision: 20, scale: 8, nullable: true }) minGasPrice: number; - @Column({ type: 'decimal', precision: 20, scale: 8, nullable: true }) + @Column({ type: "decimal", precision: 20, scale: 8, nullable: true }) maxGasPrice: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) gasPriceVolatility: number; - @Column({ type: 'decimal', precision: 20, scale: 8, nullable: true }) + @Column({ type: "decimal", precision: 20, scale: 8, nullable: true }) averageTransactionFee: number; // Network performance metrics - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) averageLatency: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) latencyVolatility: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) networkCongestionScore: number; // Reliability score components - @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) stabilityScore: number; - @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) costEfficiencyScore: number; - @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) overallReliabilityScore: number; } diff --git a/apps/api-service/src/chain-reliability/index.ts b/apps/api-service/src/chain-reliability/index.ts index ab15b39..e7a7a81 100644 --- a/apps/api-service/src/chain-reliability/index.ts +++ b/apps/api-service/src/chain-reliability/index.ts @@ -1,7 +1,7 @@ // Chain Reliability Module -export * from './chain-reliability.module'; -export * from './services/chain-reliability.service'; -export * from './controllers/leaderboard.controller'; -export * from './entities/chain-performance-metric.entity'; -export * from './interfaces/chain-reliability.interface'; -export * from './scheduled-metrics.job'; +export * from "./chain-reliability.module"; +export * from "./services/chain-reliability.service"; +export * from "./controllers/leaderboard.controller"; +export * from "./entities/chain-performance-metric.entity"; +export * from "./interfaces/chain-reliability.interface"; +export * from "./scheduled-metrics.job"; diff --git a/apps/api-service/src/chain-reliability/interfaces/chain-reliability.interface.ts b/apps/api-service/src/chain-reliability/interfaces/chain-reliability.interface.ts index ddd2dba..a2bfea1 100644 --- a/apps/api-service/src/chain-reliability/interfaces/chain-reliability.interface.ts +++ b/apps/api-service/src/chain-reliability/interfaces/chain-reliability.interface.ts @@ -1,4 +1,4 @@ -import { MetricTimeWindow } from '../entities/chain-performance-metric.entity'; +import { MetricTimeWindow } from "../entities/chain-performance-metric.entity"; export interface ChainMetrics { chainId: string; diff --git a/apps/api-service/src/chain-reliability/repositories/chain-performance-metric.repository.ts b/apps/api-service/src/chain-reliability/repositories/chain-performance-metric.repository.ts index 0b1d204..2a2993f 100644 --- a/apps/api-service/src/chain-reliability/repositories/chain-performance-metric.repository.ts +++ b/apps/api-service/src/chain-reliability/repositories/chain-performance-metric.repository.ts @@ -1,5 +1,8 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { ChainPerformanceMetric, MetricTimeWindow } from '../entities/chain-performance-metric.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { + ChainPerformanceMetric, + MetricTimeWindow, +} from "../entities/chain-performance-metric.entity"; @EntityRepository(ChainPerformanceMetric) export class ChainPerformanceMetricRepository extends Repository { @@ -9,31 +12,36 @@ export class ChainPerformanceMetricRepository extends Repository { return this.find({ where: { chainId, timeWindow }, - order: { recordedAt: 'DESC' }, + order: { recordedAt: "DESC" }, }); } - async findLatestByChain(chainId: string, timeWindow: MetricTimeWindow): Promise { + async findLatestByChain( + chainId: string, + timeWindow: MetricTimeWindow, + ): Promise { return this.findOne({ where: { chainId, timeWindow }, - order: { recordedAt: 'DESC' }, + order: { recordedAt: "DESC" }, }); } - async findAllLatest(timeWindow: MetricTimeWindow): Promise { - const subQuery = this.createQueryBuilder('metric') - .select('MAX(metric.recordedAt)', 'maxRecordedAt') - .where('metric.timeWindow = :timeWindow', { timeWindow }) - .groupBy('metric.chainId'); + async findAllLatest( + timeWindow: MetricTimeWindow, + ): Promise { + const subQuery = this.createQueryBuilder("metric") + .select("MAX(metric.recordedAt)", "maxRecordedAt") + .where("metric.timeWindow = :timeWindow", { timeWindow }) + .groupBy("metric.chainId"); - return this.createQueryBuilder('metric') + return this.createQueryBuilder("metric") .innerJoin( `(${subQuery.getQuery()})`, - 'latest', - 'metric.recordedAt = latest.maxRecordedAt AND metric.timeWindow = :timeWindow', + "latest", + "metric.recordedAt = latest.maxRecordedAt AND metric.timeWindow = :timeWindow", { timeWindow }, ) - .orderBy('metric.overallReliabilityScore', 'DESC') + .orderBy("metric.overallReliabilityScore", "DESC") .getMany(); } @@ -43,14 +51,14 @@ export class ChainPerformanceMetricRepository extends Repository { - return this.createQueryBuilder('metric') - .where('metric.chainId = :chainId', { chainId }) - .andWhere('metric.timeWindow = :timeWindow', { timeWindow }) - .andWhere('metric.recordedAt BETWEEN :startDate AND :endDate', { + return this.createQueryBuilder("metric") + .where("metric.chainId = :chainId", { chainId }) + .andWhere("metric.timeWindow = :timeWindow", { timeWindow }) + .andWhere("metric.recordedAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) - .orderBy('metric.recordedAt', 'ASC') + .orderBy("metric.recordedAt", "ASC") .getMany(); } @@ -62,10 +70,10 @@ export class ChainPerformanceMetricRepository extends Repository { - const subQuery = this.createQueryBuilder('metric') - .select('MAX(metric.recordedAt)', 'maxRecordedAt') - .where('metric.timeWindow = :timeWindow', { timeWindow }) - .groupBy('metric.chainId'); + const subQuery = this.createQueryBuilder("metric") + .select("MAX(metric.recordedAt)", "maxRecordedAt") + .where("metric.timeWindow = :timeWindow", { timeWindow }) + .groupBy("metric.chainId"); - return this.createQueryBuilder('metric') + return this.createQueryBuilder("metric") .innerJoin( `(${subQuery.getQuery()})`, - 'latest', - 'metric.recordedAt = latest.maxRecordedAt AND metric.timeWindow = :timeWindow', + "latest", + "metric.recordedAt = latest.maxRecordedAt AND metric.timeWindow = :timeWindow", { timeWindow }, ) - .orderBy('metric.overallReliabilityScore', 'DESC') + .orderBy("metric.overallReliabilityScore", "DESC") .limit(limit) .getMany(); } diff --git a/apps/api-service/src/chain-reliability/scheduled-metrics.job.ts b/apps/api-service/src/chain-reliability/scheduled-metrics.job.ts index c88026b..12b5c9c 100644 --- a/apps/api-service/src/chain-reliability/scheduled-metrics.job.ts +++ b/apps/api-service/src/chain-reliability/scheduled-metrics.job.ts @@ -1,11 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { ChainReliabilityService } from './services/chain-reliability.service'; -import { MetricTimeWindow } from './entities/chain-performance-metric.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { ChainReliabilityService } from "./services/chain-reliability.service"; +import { MetricTimeWindow } from "./entities/chain-performance-metric.entity"; /** * Scheduled jobs for automatic metrics collection - * + * * This service runs periodic tasks to collect chain performance metrics: * - Daily metrics: Every hour * - Weekly metrics: Every day at midnight @@ -15,17 +15,21 @@ import { MetricTimeWindow } from './entities/chain-performance-metric.entity'; export class ScheduledMetricsJob { private readonly logger = new Logger(ScheduledMetricsJob.name); - constructor(private readonly chainReliabilityService: ChainReliabilityService) {} + constructor( + private readonly chainReliabilityService: ChainReliabilityService, + ) {} /** * Collect daily metrics every hour */ - @Cron('0 * * * *') + @Cron("0 * * * *") async collectDailyMetrics(): Promise { - this.logger.log('Starting daily metrics collection...'); + this.logger.log("Starting daily metrics collection..."); try { - await this.chainReliabilityService.collectAllChainMetrics(MetricTimeWindow.DAILY); - this.logger.log('Daily metrics collection completed'); + await this.chainReliabilityService.collectAllChainMetrics( + MetricTimeWindow.DAILY, + ); + this.logger.log("Daily metrics collection completed"); } catch (error) { this.logger.error(`Daily metrics collection failed: ${error.message}`); } @@ -34,12 +38,14 @@ export class ScheduledMetricsJob { /** * Collect weekly metrics every day at midnight */ - @Cron('0 0 * * *') + @Cron("0 0 * * *") async collectWeeklyMetrics(): Promise { - this.logger.log('Starting weekly metrics collection...'); + this.logger.log("Starting weekly metrics collection..."); try { - await this.chainReliabilityService.collectAllChainMetrics(MetricTimeWindow.WEEKLY); - this.logger.log('Weekly metrics collection completed'); + await this.chainReliabilityService.collectAllChainMetrics( + MetricTimeWindow.WEEKLY, + ); + this.logger.log("Weekly metrics collection completed"); } catch (error) { this.logger.error(`Weekly metrics collection failed: ${error.message}`); } @@ -48,12 +54,14 @@ export class ScheduledMetricsJob { /** * Collect monthly metrics every day at 1 AM */ - @Cron('0 1 * * *') + @Cron("0 1 * * *") async collectMonthlyMetrics(): Promise { - this.logger.log('Starting monthly metrics collection...'); + this.logger.log("Starting monthly metrics collection..."); try { - await this.chainReliabilityService.collectAllChainMetrics(MetricTimeWindow.MONTHLY); - this.logger.log('Monthly metrics collection completed'); + await this.chainReliabilityService.collectAllChainMetrics( + MetricTimeWindow.MONTHLY, + ); + this.logger.log("Monthly metrics collection completed"); } catch (error) { this.logger.error(`Monthly metrics collection failed: ${error.message}`); } diff --git a/apps/api-service/src/chain-reliability/services/chain-reliability.service.ts b/apps/api-service/src/chain-reliability/services/chain-reliability.service.ts index f704079..69b35cf 100644 --- a/apps/api-service/src/chain-reliability/services/chain-reliability.service.ts +++ b/apps/api-service/src/chain-reliability/services/chain-reliability.service.ts @@ -1,16 +1,19 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ChainPerformanceMetric, MetricTimeWindow } from '../entities/chain-performance-metric.entity'; -import { Chain } from '../../database/entities/chain.entity'; -import { Transaction } from '../../database/entities/transaction.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { + ChainPerformanceMetric, + MetricTimeWindow, +} from "../entities/chain-performance-metric.entity"; +import { Chain } from "../../database/entities/chain.entity"; +import { Transaction } from "../../database/entities/transaction.entity"; import { LeaderboardEntry, LeaderboardQuery, ReliabilityScores, ChainPerformanceData, MetricsCollectionOptions, -} from '../interfaces/chain-reliability.interface'; +} from "../interfaces/chain-reliability.interface"; @Injectable() export class ChainReliabilityService { @@ -40,15 +43,18 @@ export class ChainReliabilityService { ): ReliabilityScores { // Calculate stability score (60% success rate, 40% latency) const latencyScore = this.calculateLatencyScore(averageLatency); - const stabilityScore = (successRate * 0.6) + (latencyScore * 0.4); + const stabilityScore = successRate * 0.6 + latencyScore * 0.4; // Calculate cost efficiency score (based on gas price and volatility) - const costEfficiencyScore = this.calculateCostEfficiencyScore(averageGasPrice, gasPriceVolatility); + const costEfficiencyScore = this.calculateCostEfficiencyScore( + averageGasPrice, + gasPriceVolatility, + ); // Calculate overall reliability score - const overallReliabilityScore = - (stabilityScore * this.STABILITY_WEIGHT) + - (costEfficiencyScore * this.COST_EFFICIENCY_WEIGHT); + const overallReliabilityScore = + stabilityScore * this.STABILITY_WEIGHT + + costEfficiencyScore * this.COST_EFFICIENCY_WEIGHT; return { stabilityScore: Math.round(stabilityScore * 100) / 100, @@ -71,21 +77,27 @@ export class ChainReliabilityService { /** * Calculate cost efficiency score (0-100) - lower gas price and volatility is better */ - private calculateCostEfficiencyScore(averageGasPrice: number, gasVolatility: number): number { + private calculateCostEfficiencyScore( + averageGasPrice: number, + gasVolatility: number, + ): number { // Normalize gas price (assuming range 0.001 - 1 ETH = 0 - 100 score) - const gasPriceScore = Math.max(0, 100 - (averageGasPrice * 100)); + const gasPriceScore = Math.max(0, 100 - averageGasPrice * 100); // Volatility score (lower volatility is better) // Assuming volatility range 0 - 1 - const volatilityScore = Math.max(0, 100 - (gasVolatility * 100)); + const volatilityScore = Math.max(0, 100 - gasVolatility * 100); - return (gasPriceScore * 0.6) + (volatilityScore * 0.4); + return gasPriceScore * 0.6 + volatilityScore * 0.4; } /** * Collect metrics for a specific chain and time window */ - async collectMetrics(chainId: string, timeWindow: MetricTimeWindow): Promise { + async collectMetrics( + chainId: string, + timeWindow: MetricTimeWindow, + ): Promise { const chain = await this.chainRepository.findOne({ where: { chainId } }); if (!chain) { throw new Error(`Chain with id ${chainId} not found`); @@ -94,7 +106,11 @@ export class ChainReliabilityService { const { startDate, endDate } = this.getDateRangeForTimeWindow(timeWindow); // Get transaction metrics - const transactionMetrics = await this.getTransactionMetrics(chainId, startDate, endDate); + const transactionMetrics = await this.getTransactionMetrics( + chainId, + startDate, + endDate, + ); // Get gas metrics const gasMetrics = await this.getGasMetrics(chainId, startDate, endDate); @@ -165,14 +181,25 @@ export class ChainReliabilityService { chainId: string, startDate: Date, endDate: Date, - ): Promise<{ totalTransactions: number; successfulTransactions: number; failedTransactions: number; successRate: number }> { + ): Promise<{ + totalTransactions: number; + successfulTransactions: number; + failedTransactions: number; + successRate: number; + }> { const result = await this.transactionRepository - .createQueryBuilder('transaction') - .select('COUNT(transaction.id)', 'totalTransactions') - .addSelect("COUNT(CASE WHEN transaction.status = 'success' THEN 1 END)", 'successfulTransactions') - .addSelect("COUNT(CASE WHEN transaction.status = 'failed' THEN 1 END)", 'failedTransactions') - .where('transaction.chainId = :chainId', { chainId }) - .andWhere('transaction.createdAt BETWEEN :startDate AND :endDate', { + .createQueryBuilder("transaction") + .select("COUNT(transaction.id)", "totalTransactions") + .addSelect( + "COUNT(CASE WHEN transaction.status = 'success' THEN 1 END)", + "successfulTransactions", + ) + .addSelect( + "COUNT(CASE WHEN transaction.status = 'failed' THEN 1 END)", + "failedTransactions", + ) + .where("transaction.chainId = :chainId", { chainId }) + .andWhere("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) @@ -181,7 +208,10 @@ export class ChainReliabilityService { const totalTransactions = parseInt(result.totalTransactions) || 0; const successfulTransactions = parseInt(result.successfulTransactions) || 0; const failedTransactions = parseInt(result.failedTransactions) || 0; - const successRate = totalTransactions > 0 ? (successfulTransactions / totalTransactions) * 100 : 0; + const successRate = + totalTransactions > 0 + ? (successfulTransactions / totalTransactions) * 100 + : 0; return { totalTransactions, @@ -206,14 +236,14 @@ export class ChainReliabilityService { averageTransactionFee: number; }> { const result = await this.transactionRepository - .createQueryBuilder('transaction') - .select('AVG(transaction.gasPrice)', 'avgGasPrice') - .addSelect('MIN(transaction.gasPrice)', 'minGasPrice') - .addSelect('MAX(transaction.gasPrice)', 'maxGasPrice') - .addSelect('AVG(transaction.transactionFee)', 'avgTransactionFee') - .addSelect('STDDEV(transaction.gasPrice)', 'gasPriceStdDev') - .where('transaction.chainId = :chainId', { chainId }) - .andWhere('transaction.createdAt BETWEEN :startDate AND :endDate', { + .createQueryBuilder("transaction") + .select("AVG(transaction.gasPrice)", "avgGasPrice") + .addSelect("MIN(transaction.gasPrice)", "minGasPrice") + .addSelect("MAX(transaction.gasPrice)", "maxGasPrice") + .addSelect("AVG(transaction.transactionFee)", "avgTransactionFee") + .addSelect("STDDEV(transaction.gasPrice)", "gasPriceStdDev") + .where("transaction.chainId = :chainId", { chainId }) + .andWhere("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) @@ -239,7 +269,10 @@ export class ChainReliabilityService { /** * Get date range for a given time window */ - private getDateRangeForTimeWindow(timeWindow: MetricTimeWindow): { startDate: Date; endDate: Date } { + private getDateRangeForTimeWindow(timeWindow: MetricTimeWindow): { + startDate: Date; + endDate: Date; + } { const endDate = new Date(); const startDate = new Date(); @@ -275,7 +308,7 @@ export class ChainReliabilityService { let rank = 1; for (const metric of metrics) { - const chain = chains.find(c => c.chainId === metric.chainId); + const chain = chains.find((c) => c.chainId === metric.chainId); if (!chain) continue; leaderboard.push({ @@ -298,14 +331,16 @@ export class ChainReliabilityService { /** * Get latest metrics for all chains */ - private async getLatestMetricsForAllChains(timeWindow: MetricTimeWindow): Promise { + private async getLatestMetricsForAllChains( + timeWindow: MetricTimeWindow, + ): Promise { const chains = await this.chainRepository.find(); const latestMetrics: ChainPerformanceMetric[] = []; for (const chain of chains) { const metric = await this.metricRepository.findOne({ where: { chainId: chain.chainId, timeWindow }, - order: { recordedAt: 'DESC' }, + order: { recordedAt: "DESC" }, }); if (metric) { @@ -313,8 +348,9 @@ export class ChainReliabilityService { } } - return latestMetrics.sort((a, b) => - Number(b.overallReliabilityScore) - Number(a.overallReliabilityScore) + return latestMetrics.sort( + (a, b) => + Number(b.overallReliabilityScore) - Number(a.overallReliabilityScore), ); } @@ -328,7 +364,7 @@ export class ChainReliabilityService { ): Promise { return this.metricRepository.find({ where: { chainId, timeWindow }, - order: { recordedAt: 'DESC' }, + order: { recordedAt: "DESC" }, take: limit, }); } @@ -337,14 +373,18 @@ export class ChainReliabilityService { * Trigger metrics collection for all active chains */ async collectAllChainMetrics(timeWindow: MetricTimeWindow): Promise { - const chains = await this.chainRepository.find({ where: { status: 'active' } }); + const chains = await this.chainRepository.find({ + where: { status: "active" }, + }); for (const chain of chains) { try { await this.collectMetrics(chain.chainId, timeWindow); this.logger.log(`Collected metrics for chain ${chain.chainId}`); } catch (error) { - this.logger.error(`Failed to collect metrics for chain ${chain.chainId}: ${error.message}`); + this.logger.error( + `Failed to collect metrics for chain ${chain.chainId}: ${error.message}`, + ); } } } diff --git a/apps/api-service/src/class-validator.d.ts b/apps/api-service/src/class-validator.d.ts index c9c71d8..3839688 100644 --- a/apps/api-service/src/class-validator.d.ts +++ b/apps/api-service/src/class-validator.d.ts @@ -1,10 +1,19 @@ -declare module 'class-validator' { +declare module "class-validator" { export function IsString(validationOptions?: any): PropertyDecorator; - export function IsNumber(args?: any, validationOptions?: any): PropertyDecorator; + export function IsNumber( + args?: any, + validationOptions?: any, + ): PropertyDecorator; export function IsDate(validationOptions?: any): PropertyDecorator; - export function IsEnum(entity: any, validationOptions?: any): PropertyDecorator; + export function IsEnum( + entity: any, + validationOptions?: any, + ): PropertyDecorator; export function IsOptional(validationOptions?: any): PropertyDecorator; export function Min(min: number, validationOptions?: any): PropertyDecorator; export function Max(max: number, validationOptions?: any): PropertyDecorator; - export function IsIn(values: any[], validationOptions?: any): PropertyDecorator; + export function IsIn( + values: any[], + validationOptions?: any, + ): PropertyDecorator; } diff --git a/apps/api-service/src/config/database.config.ts b/apps/api-service/src/config/database.config.ts index d406cd7..559bf53 100644 --- a/apps/api-service/src/config/database.config.ts +++ b/apps/api-service/src/config/database.config.ts @@ -1,18 +1,24 @@ export default () => ({ database: { - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432', 10), - username: process.env.DATABASE_USERNAME || 'postgres', - password: process.env.DATABASE_PASSWORD || 'postgres', - name: process.env.DATABASE_NAME || 'gasguard', - synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', - logging: process.env.DATABASE_LOGGING === 'true', - ssl: process.env.DATABASE_SSL === 'true', - maxQueryExecutionTime: parseInt(process.env.DATABASE_MAX_QUERY_TIME || '1000', 10), + host: process.env.DATABASE_HOST || "localhost", + port: parseInt(process.env.DATABASE_PORT || "5432", 10), + username: process.env.DATABASE_USERNAME || "postgres", + password: process.env.DATABASE_PASSWORD || "postgres", + name: process.env.DATABASE_NAME || "gasguard", + synchronize: process.env.DATABASE_SYNCHRONIZE === "true", + logging: process.env.DATABASE_LOGGING === "true", + ssl: process.env.DATABASE_SSL === "true", + maxQueryExecutionTime: parseInt( + process.env.DATABASE_MAX_QUERY_TIME || "1000", + 10, + ), // Connection pool settings - maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || '10', 10), - minConnections: parseInt(process.env.DATABASE_MIN_CONNECTIONS || '1', 10), - connectionTimeout: parseInt(process.env.DATABASE_CONNECTION_TIMEOUT || '30000', 10), - idleTimeout: parseInt(process.env.DATABASE_IDLE_TIMEOUT || '10000', 10), + maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || "10", 10), + minConnections: parseInt(process.env.DATABASE_MIN_CONNECTIONS || "1", 10), + connectionTimeout: parseInt( + process.env.DATABASE_CONNECTION_TIMEOUT || "30000", + 10, + ), + idleTimeout: parseInt(process.env.DATABASE_IDLE_TIMEOUT || "10000", 10), }, -}); \ No newline at end of file +}); diff --git a/apps/api-service/src/database/database.module.ts b/apps/api-service/src/database/database.module.ts index e124173..6716418 100644 --- a/apps/api-service/src/database/database.module.ts +++ b/apps/api-service/src/database/database.module.ts @@ -1,39 +1,41 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { - Transaction, - Merchant, - Chain, - AnalysisResult, - User, -} from './entities'; -import { ChainPerformanceMetric } from '../chain-reliability/entities/chain-performance-metric.entity'; -import { ApiPerformanceMetric, ApiPerformanceAggregate } from '../performance-monitoring/entities/api-performance-metric.entity'; -import { GasSubsidyCap, GasSubsidyUsageLog, GasSubsidyAlert, SuspiciousUsageFlag } from '../gas-subsidy/entities/gas-subsidy.entity'; -import { AuditLog, ApiKey } from '../audit/entities'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { Transaction, Merchant, Chain, AnalysisResult, User } from "./entities"; +import { ChainPerformanceMetric } from "../chain-reliability/entities/chain-performance-metric.entity"; +import { + ApiPerformanceMetric, + ApiPerformanceAggregate, +} from "../performance-monitoring/entities/api-performance-metric.entity"; +import { + GasSubsidyCap, + GasSubsidyUsageLog, + GasSubsidyAlert, + SuspiciousUsageFlag, +} from "../gas-subsidy/entities/gas-subsidy.entity"; +import { AuditLog, ApiKey } from "../audit/entities"; import { SuspiciousGasPattern, GasPatternDetectionLog, -} from '../analytics/entities/suspicious-gas-pattern.entity'; -import { GasBaseline } from '../analytics/entities/gas-baseline.entity'; +} from "../analytics/entities/suspicious-gas-pattern.entity"; +import { GasBaseline } from "../analytics/entities/gas-baseline.entity"; @Module({ imports: [ TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ - type: 'postgres', - host: configService.get('DATABASE_HOST', 'localhost'), - port: configService.get('DATABASE_PORT', 5432), - username: configService.get('DATABASE_USERNAME', 'postgres'), - password: configService.get('DATABASE_PASSWORD', 'postgres'), - database: configService.get('DATABASE_NAME', 'gasguard'), + type: "postgres", + host: configService.get("DATABASE_HOST", "localhost"), + port: configService.get("DATABASE_PORT", 5432), + username: configService.get("DATABASE_USERNAME", "postgres"), + password: configService.get("DATABASE_PASSWORD", "postgres"), + database: configService.get("DATABASE_NAME", "gasguard"), entities: [ - Transaction, - Merchant, - Chain, - AnalysisResult, + Transaction, + Merchant, + Chain, + AnalysisResult, User, ChainPerformanceMetric, ApiPerformanceMetric, @@ -48,18 +50,18 @@ import { GasBaseline } from '../analytics/entities/gas-baseline.entity'; GasPatternDetectionLog, GasBaseline, ], - synchronize: configService.get('DATABASE_SYNCHRONIZE', false), - logging: configService.get('DATABASE_LOGGING', false), + synchronize: configService.get("DATABASE_SYNCHRONIZE", false), + logging: configService.get("DATABASE_LOGGING", false), maxQueryExecutionTime: 1000, - ssl: configService.get('DATABASE_SYNCHRONIZE', false), + ssl: configService.get("DATABASE_SYNCHRONIZE", false), }), inject: [ConfigService], }), TypeOrmModule.forFeature([ - Transaction, - Merchant, - Chain, - AnalysisResult, + Transaction, + Merchant, + Chain, + AnalysisResult, User, ChainPerformanceMetric, ApiPerformanceMetric, diff --git a/apps/api-service/src/database/entities/analysis-result.entity.ts b/apps/api-service/src/database/entities/analysis-result.entity.ts index 5ad8bf7..a4ab068 100644 --- a/apps/api-service/src/database/entities/analysis-result.entity.ts +++ b/apps/api-service/src/database/entities/analysis-result.entity.ts @@ -1,70 +1,77 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('analysis_results') +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("analysis_results") export class AnalysisResult { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_analysis_merchant_id') + @Column({ type: "varchar", length: 100 }) + @Index("idx_analysis_merchant_id") merchantId: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_analysis_chain_id') + @Column({ type: "varchar", length: 50 }) + @Index("idx_analysis_chain_id") chainId: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_analysis_contract_address') + @Column({ type: "varchar", length: 100 }) + @Index("idx_analysis_contract_address") contractAddress: string; - @Column({ type: 'text' }) + @Column({ type: "text" }) sourceCode: string; - @Column({ type: 'varchar', length: 20 }) - @Index('idx_analysis_language') + @Column({ type: "varchar", length: 20 }) + @Index("idx_analysis_language") language: string; // 'solidity', 'rust', 'vyper' - @Column({ type: 'varchar', length: 20 }) - @Index('idx_analysis_status') + @Column({ type: "varchar", length: 20 }) + @Index("idx_analysis_status") status: string; // 'completed', 'failed', 'pending' - @Column({ type: 'jsonb' }) + @Column({ type: "jsonb" }) findings: any[]; // Array of rule violations - @Column({ type: 'integer' }) - @Index('idx_analysis_violation_count') + @Column({ type: "integer" }) + @Index("idx_analysis_violation_count") violationCount: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) - @Index('idx_analysis_gas_savings') + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) + @Index("idx_analysis_gas_savings") estimatedGasSavings?: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) - @Index('idx_analysis_cost_savings') + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) + @Index("idx_analysis_cost_savings") estimatedCostSavings?: number; - @Column({ type: 'timestamp' }) - @Index('idx_analysis_created_at') + @Column({ type: "timestamp" }) + @Index("idx_analysis_created_at") createdAt: Date; @CreateDateColumn() - @Index('idx_analysis_created_at_auto') + @Index("idx_analysis_created_at_auto") createdAtAuto: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'varchar', length: 100, nullable: true }) - @Index('idx_analysis_version') + @Column({ type: "varchar", length: 100, nullable: true }) + @Index("idx_analysis_version") analyzerVersion?: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata?: Record; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_analysis_priority') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_analysis_priority") priority?: string; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) errorMessage?: string; -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/entities/chain.entity.ts b/apps/api-service/src/database/entities/chain.entity.ts index c72dce3..ad71866 100644 --- a/apps/api-service/src/database/entities/chain.entity.ts +++ b/apps/api-service/src/database/entities/chain.entity.ts @@ -1,64 +1,71 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('chains') +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("chains") export class Chain { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 50, unique: true }) - @Index('idx_chain_name') + @Column({ type: "varchar", length: 50, unique: true }) + @Index("idx_chain_name") name: string; - @Column({ type: 'varchar', length: 50, unique: true }) - @Index('idx_chain_id_unique') + @Column({ type: "varchar", length: 50, unique: true }) + @Index("idx_chain_id_unique") chainId: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_chain_network') + @Column({ type: "varchar", length: 100 }) + @Index("idx_chain_network") network: string; // 'mainnet', 'testnet', 'devnet' - @Column({ type: 'varchar', length: 50 }) - @Index('idx_chain_status') + @Column({ type: "varchar", length: 50 }) + @Index("idx_chain_status") status: string; // 'active', 'inactive', 'maintenance' - @Column({ type: 'varchar', length: 100 }) - @Index('idx_chain_type') + @Column({ type: "varchar", length: 100 }) + @Index("idx_chain_type") type: string; // 'evm', 'soroban', 'cosmos', 'other' - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) - @Index('idx_chain_gas_price') + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) + @Index("idx_chain_gas_price") averageGasPrice?: number; - @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) - @Index('idx_chain_gas_volatility') + @Column({ type: "decimal", precision: 10, scale: 2, nullable: true }) + @Index("idx_chain_gas_volatility") gasVolatility?: number; // Standard deviation of gas prices - @Column({ type: 'integer', default: 0 }) - @Index('idx_chain_transaction_count') + @Column({ type: "integer", default: 0 }) + @Index("idx_chain_transaction_count") transactionCount: number; - @Column({ type: 'decimal', precision: 10, scale: 2, default: 100 }) - @Index('idx_chain_reliability') + @Column({ type: "decimal", precision: 10, scale: 2, default: 100 }) + @Index("idx_chain_reliability") reliabilityScore: number; // 0-100 score - @Column({ type: 'timestamp' }) - @Index('idx_chain_created_at') + @Column({ type: "timestamp" }) + @Index("idx_chain_created_at") createdAt: Date; @CreateDateColumn() - @Index('idx_chain_created_at_auto') + @Index("idx_chain_created_at_auto") createdAtAuto: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) config?: Record; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) rpcUrl?: string; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_chain_currency') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_chain_currency") currency?: string; -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/entities/index.ts b/apps/api-service/src/database/entities/index.ts index 5e91acd..3a9dfd7 100644 --- a/apps/api-service/src/database/entities/index.ts +++ b/apps/api-service/src/database/entities/index.ts @@ -1,5 +1,5 @@ -export * from './transaction.entity'; -export * from './merchant.entity'; -export * from './chain.entity'; -export * from './analysis-result.entity'; -export * from './user.entity'; \ No newline at end of file +export * from "./transaction.entity"; +export * from "./merchant.entity"; +export * from "./chain.entity"; +export * from "./analysis-result.entity"; +export * from "./user.entity"; diff --git a/apps/api-service/src/database/entities/merchant.entity.ts b/apps/api-service/src/database/entities/merchant.entity.ts index d3e50da..924e5d0 100644 --- a/apps/api-service/src/database/entities/merchant.entity.ts +++ b/apps/api-service/src/database/entities/merchant.entity.ts @@ -1,67 +1,74 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('merchants') +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("merchants") export class Merchant { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100, unique: true }) - @Index('idx_merchant_name') + @Column({ type: "varchar", length: 100, unique: true }) + @Index("idx_merchant_name") name: string; - @Column({ type: 'varchar', length: 100, unique: true }) - @Index('idx_merchant_slug') + @Column({ type: "varchar", length: 100, unique: true }) + @Index("idx_merchant_slug") slug: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) description: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_merchant_status') + @Column({ type: "varchar", length: 50 }) + @Index("idx_merchant_status") status: string; // 'active', 'inactive', 'suspended' - @Column({ type: 'varchar', length: 100 }) - @Index('idx_merchant_plan') + @Column({ type: "varchar", length: 100 }) + @Index("idx_merchant_plan") plan: string; // 'free', 'pro', 'enterprise' - @Column({ type: 'varchar', length: 100 }) - @Index('idx_merchant_tier') + @Column({ type: "varchar", length: 100 }) + @Index("idx_merchant_tier") tier: string; // 'basic', 'standard', 'premium' - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) website?: string; - @Column({ type: 'varchar', length: 255, nullable: true }) - @Index('idx_merchant_email') + @Column({ type: "varchar", length: 255, nullable: true }) + @Index("idx_merchant_email") email?: string; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_merchant_country') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_merchant_country") country?: string; - @Column({ type: 'timestamp', nullable: true }) - @Index('idx_merchant_last_active') + @Column({ type: "timestamp", nullable: true }) + @Index("idx_merchant_last_active") lastActiveAt?: Date; - @Column({ type: 'timestamp' }) - @Index('idx_merchant_created_at') + @Column({ type: "timestamp" }) + @Index("idx_merchant_created_at") createdAt: Date; @CreateDateColumn() - @Index('idx_merchant_created_at_auto') + @Index("idx_merchant_created_at_auto") createdAtAuto: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'boolean', default: false }) - @Index('idx_merchant_verified') + @Column({ type: "boolean", default: false }) + @Index("idx_merchant_verified") isVerified: boolean; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata?: Record; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_merchant_category') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_merchant_category") category?: string; -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/entities/transaction.entity.ts b/apps/api-service/src/database/entities/transaction.entity.ts index c653a3b..63a4c90 100644 --- a/apps/api-service/src/database/entities/transaction.entity.ts +++ b/apps/api-service/src/database/entities/transaction.entity.ts @@ -1,77 +1,84 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -@Entity('transactions') +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("transactions") export class Transaction { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_transaction_hash') + @Column({ type: "varchar", length: 100 }) + @Index("idx_transaction_hash") transactionHash: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_merchant_id') + @Column({ type: "varchar", length: 100 }) + @Index("idx_merchant_id") merchantId: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_chain_id') + @Column({ type: "varchar", length: 50 }) + @Index("idx_chain_id") chainId: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_contract_address') + @Column({ type: "varchar", length: 50 }) + @Index("idx_contract_address") contractAddress: string; - @Column({ type: 'decimal', precision: 30, scale: 18 }) - @Index('idx_gas_used') + @Column({ type: "decimal", precision: 30, scale: 18 }) + @Index("idx_gas_used") gasUsed: number; - @Column({ type: 'decimal', precision: 30, scale: 18, nullable: true }) + @Column({ type: "decimal", precision: 30, scale: 18, nullable: true }) gasPrice?: number; - @Column({ type: 'decimal', precision: 30, scale: 18 }) + @Column({ type: "decimal", precision: 30, scale: 18 }) transactionFee: number; - @Column({ type: 'varchar', length: 20 }) - @Index('idx_status') + @Column({ type: "varchar", length: 20 }) + @Index("idx_status") status: string; // 'success', 'failed', 'pending' - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) errorMessage?: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_transaction_type') + @Column({ type: "varchar", length: 50 }) + @Index("idx_transaction_type") transactionType: string; // 'deployment', 'function_call', 'transfer' - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) functionName?: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) functionParams?: Record; - @Column({ type: 'timestamp' }) - @Index('idx_created_at') + @Column({ type: "timestamp" }) + @Index("idx_created_at") createdAt: Date; @CreateDateColumn() - @Index('idx_created_at_auto') + @Index("idx_created_at_auto") createdAtAuto: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_region') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_region") region?: string; - @Column({ type: 'varchar', length: 100, nullable: true }) - @Index('idx_user_id') + @Column({ type: "varchar", length: 100, nullable: true }) + @Index("idx_user_id") userId?: string; - @Column({ type: 'integer', default: 0 }) - @Index('idx_retry_count') + @Column({ type: "integer", default: 0 }) + @Index("idx_retry_count") retryCount: number; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_priority') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_priority") priority?: string; // 'low', 'medium', 'high', 'critical' -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/entities/user.entity.ts b/apps/api-service/src/database/entities/user.entity.ts index 0e9dc27..a513de3 100644 --- a/apps/api-service/src/database/entities/user.entity.ts +++ b/apps/api-service/src/database/entities/user.entity.ts @@ -1,68 +1,74 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm'; -import { UserRole } from '../../rbac/enums/role.enum'; - - -@Entity('users') +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { UserRole } from "../../rbac/enums/role.enum"; + +@Entity("users") export class User { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_user_email', { unique: true }) + @Column({ type: "varchar", length: 100 }) + @Index("idx_user_email", { unique: true }) email: string; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) firstName?: string; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) lastName?: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) passwordHash: string; @Column({ - type: 'enum', + type: "enum", enum: UserRole, default: UserRole.VIEWER, }) - @Index('idx_user_role') + @Index("idx_user_role") role: UserRole; - @Column({ type: 'uuid', nullable: true }) - @Index('idx_user_merchant_id') + @Column({ type: "uuid", nullable: true }) + @Index("idx_user_merchant_id") merchantId?: string; - @Column({ type: 'boolean', default: true }) - @Index('idx_user_is_active') + @Column({ type: "boolean", default: true }) + @Index("idx_user_is_active") isActive: boolean; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) lastLoginAt?: Date; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) lastLoginIp?: string; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) passwordChangedAt?: Date; - @Column({ type: 'integer', default: 0 }) + @Column({ type: "integer", default: 0 }) failedLoginAttempts: number; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) lockedUntil?: Date; @CreateDateColumn() - @Index('idx_user_created_at') + @Index("idx_user_created_at") createdAt: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'uuid', nullable: true }) - @Index('idx_user_created_by') + @Column({ type: "uuid", nullable: true }) + @Index("idx_user_created_by") createdBy?: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata?: Record; /** @@ -75,7 +81,6 @@ export class User { return this.firstName || this.lastName || this.email; } - isLocked(): boolean { if (!this.lockedUntil) { return false; @@ -83,7 +88,6 @@ export class User { return new Date() < this.lockedUntil; } - canAuthenticate(): boolean { return this.isActive && !this.isLocked(); } diff --git a/apps/api-service/src/database/migrations/1708480000000-CreateInitialSchema.ts b/apps/api-service/src/database/migrations/1708480000000-CreateInitialSchema.ts index 737e41a..fe16924 100644 --- a/apps/api-service/src/database/migrations/1708480000000-CreateInitialSchema.ts +++ b/apps/api-service/src/database/migrations/1708480000000-CreateInitialSchema.ts @@ -2,11 +2,11 @@ import { MigrationInterface, QueryRunner } from "typeorm"; import { DatabaseIndexOptimization } from "../optimization/index-optimization"; export class CreateInitialSchema1708480000000 implements MigrationInterface { - name = 'CreateInitialSchema1708480000000' + name = "CreateInitialSchema1708480000000"; - public async up(queryRunner: QueryRunner): Promise { - // Create tables - await queryRunner.query(` + public async up(queryRunner: QueryRunner): Promise { + // Create tables + await queryRunner.query(` CREATE TABLE "transactions" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transaction_hash" character varying(100) NOT NULL, @@ -32,7 +32,7 @@ export class CreateInitialSchema1708480000000 implements MigrationInterface { ) `); - await queryRunner.query(` + await queryRunner.query(` CREATE TABLE "merchants" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, @@ -55,7 +55,7 @@ export class CreateInitialSchema1708480000000 implements MigrationInterface { ) `); - await queryRunner.query(` + await queryRunner.query(` CREATE TABLE "chains" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(50) NOT NULL, @@ -77,7 +77,7 @@ export class CreateInitialSchema1708480000000 implements MigrationInterface { ) `); - await queryRunner.query(` + await queryRunner.query(` CREATE TABLE "analysis_results" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "merchant_id" character varying(100) NOT NULL, @@ -101,105 +101,211 @@ export class CreateInitialSchema1708480000000 implements MigrationInterface { ) `); - // Create basic indexes - await queryRunner.query(`CREATE INDEX "idx_transaction_hash" ON "transactions" ("transaction_hash")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_id" ON "transactions" ("merchant_id")`); - await queryRunner.query(`CREATE INDEX "idx_chain_id" ON "transactions" ("chain_id")`); - await queryRunner.query(`CREATE INDEX "idx_contract_address" ON "transactions" ("contract_address")`); - await queryRunner.query(`CREATE INDEX "idx_gas_used" ON "transactions" ("gas_used")`); - await queryRunner.query(`CREATE INDEX "idx_status" ON "transactions" ("status")`); - await queryRunner.query(`CREATE INDEX "idx_transaction_type" ON "transactions" ("transaction_type")`); - await queryRunner.query(`CREATE INDEX "idx_created_at" ON "transactions" ("created_at")`); - await queryRunner.query(`CREATE INDEX "idx_created_at_auto" ON "transactions" ("created_at_auto")`); - await queryRunner.query(`CREATE INDEX "idx_region" ON "transactions" ("region")`); - await queryRunner.query(`CREATE INDEX "idx_user_id" ON "transactions" ("user_id")`); - await queryRunner.query(`CREATE INDEX "idx_retry_count" ON "transactions" ("retry_count")`); - await queryRunner.query(`CREATE INDEX "idx_priority" ON "transactions" ("priority")`); + // Create basic indexes + await queryRunner.query( + `CREATE INDEX "idx_transaction_hash" ON "transactions" ("transaction_hash")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_id" ON "transactions" ("merchant_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_id" ON "transactions" ("chain_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_contract_address" ON "transactions" ("contract_address")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_gas_used" ON "transactions" ("gas_used")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_status" ON "transactions" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_transaction_type" ON "transactions" ("transaction_type")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_created_at" ON "transactions" ("created_at")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_created_at_auto" ON "transactions" ("created_at_auto")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_region" ON "transactions" ("region")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_user_id" ON "transactions" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_retry_count" ON "transactions" ("retry_count")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_priority" ON "transactions" ("priority")`, + ); - await queryRunner.query(`CREATE INDEX "idx_merchant_name" ON "merchants" ("name")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_slug" ON "merchants" ("slug")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_status" ON "merchants" ("status")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_plan" ON "merchants" ("plan")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_tier" ON "merchants" ("tier")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_email" ON "merchants" ("email")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_country" ON "merchants" ("country")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_last_active" ON "merchants" ("last_active_at")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_created_at" ON "merchants" ("created_at")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_created_at_auto" ON "merchants" ("created_at_auto")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_verified" ON "merchants" ("is_verified")`); - await queryRunner.query(`CREATE INDEX "idx_merchant_category" ON "merchants" ("category")`); + await queryRunner.query( + `CREATE INDEX "idx_merchant_name" ON "merchants" ("name")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_slug" ON "merchants" ("slug")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_status" ON "merchants" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_plan" ON "merchants" ("plan")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_tier" ON "merchants" ("tier")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_email" ON "merchants" ("email")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_country" ON "merchants" ("country")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_last_active" ON "merchants" ("last_active_at")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_created_at" ON "merchants" ("created_at")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_created_at_auto" ON "merchants" ("created_at_auto")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_verified" ON "merchants" ("is_verified")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_merchant_category" ON "merchants" ("category")`, + ); - await queryRunner.query(`CREATE INDEX "idx_chain_name" ON "chains" ("name")`); - await queryRunner.query(`CREATE INDEX "idx_chain_id_unique" ON "chains" ("chain_id")`); - await queryRunner.query(`CREATE INDEX "idx_chain_network" ON "chains" ("network")`); - await queryRunner.query(`CREATE INDEX "idx_chain_status" ON "chains" ("status")`); - await queryRunner.query(`CREATE INDEX "idx_chain_type" ON "chains" ("type")`); - await queryRunner.query(`CREATE INDEX "idx_chain_gas_price" ON "chains" ("average_gas_price")`); - await queryRunner.query(`CREATE INDEX "idx_chain_gas_volatility" ON "chains" ("gas_volatility")`); - await queryRunner.query(`CREATE INDEX "idx_chain_transaction_count" ON "chains" ("transaction_count")`); - await queryRunner.query(`CREATE INDEX "idx_chain_reliability" ON "chains" ("reliability_score")`); - await queryRunner.query(`CREATE INDEX "idx_chain_created_at" ON "chains" ("created_at")`); - await queryRunner.query(`CREATE INDEX "idx_chain_created_at_auto" ON "chains" ("created_at_auto")`); - await queryRunner.query(`CREATE INDEX "idx_chain_currency" ON "chains" ("currency")`); + await queryRunner.query( + `CREATE INDEX "idx_chain_name" ON "chains" ("name")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_id_unique" ON "chains" ("chain_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_network" ON "chains" ("network")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_status" ON "chains" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_type" ON "chains" ("type")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_gas_price" ON "chains" ("average_gas_price")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_gas_volatility" ON "chains" ("gas_volatility")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_transaction_count" ON "chains" ("transaction_count")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_reliability" ON "chains" ("reliability_score")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_created_at" ON "chains" ("created_at")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_created_at_auto" ON "chains" ("created_at_auto")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_chain_currency" ON "chains" ("currency")`, + ); - await queryRunner.query(`CREATE INDEX "idx_analysis_merchant_id" ON "analysis_results" ("merchant_id")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_chain_id" ON "analysis_results" ("chain_id")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_contract_address" ON "analysis_results" ("contract_address")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_language" ON "analysis_results" ("language")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_status" ON "analysis_results" ("status")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_violation_count" ON "analysis_results" ("violation_count")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_gas_savings" ON "analysis_results" ("estimated_gas_savings")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_cost_savings" ON "analysis_results" ("estimated_cost_savings")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_created_at" ON "analysis_results" ("created_at")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_created_at_auto" ON "analysis_results" ("created_at_auto")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_version" ON "analysis_results" ("analyzer_version")`); - await queryRunner.query(`CREATE INDEX "idx_analysis_priority" ON "analysis_results" ("priority")`); + await queryRunner.query( + `CREATE INDEX "idx_analysis_merchant_id" ON "analysis_results" ("merchant_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_chain_id" ON "analysis_results" ("chain_id")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_contract_address" ON "analysis_results" ("contract_address")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_language" ON "analysis_results" ("language")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_status" ON "analysis_results" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_violation_count" ON "analysis_results" ("violation_count")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_gas_savings" ON "analysis_results" ("estimated_gas_savings")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_cost_savings" ON "analysis_results" ("estimated_cost_savings")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_created_at" ON "analysis_results" ("created_at")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_created_at_auto" ON "analysis_results" ("created_at_auto")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_version" ON "analysis_results" ("analyzer_version")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_analysis_priority" ON "analysis_results" ("priority")`, + ); - // Apply optimized indexes for analytics - await DatabaseIndexOptimization.applyOptimizedIndexes(queryRunner); + // Apply optimized indexes for analytics + await DatabaseIndexOptimization.applyOptimizedIndexes(queryRunner); - // Create unique constraints - await queryRunner.query(`ALTER TABLE "merchants" ADD CONSTRAINT "UQ_merchant_name" UNIQUE ("name")`); - await queryRunner.query(`ALTER TABLE "merchants" ADD CONSTRAINT "UQ_merchant_slug" UNIQUE ("slug")`); - await queryRunner.query(`ALTER TABLE "chains" ADD CONSTRAINT "UQ_chain_name" UNIQUE ("name")`); - await queryRunner.query(`ALTER TABLE "chains" ADD CONSTRAINT "UQ_chain_id" UNIQUE ("chain_id")`); - } - - public async down(queryRunner: QueryRunner): Promise { - // Drop optimized indexes first - const optimizedIndexes = [ - 'idx_merchant_chain_date', - 'idx_merchant_status_date', - 'idx_merchant_gas_date', - 'idx_chain_status_date', - 'idx_chain_gas_date', - 'idx_chain_merchant_date', - 'idx_recent_transactions', - 'idx_high_gas_transactions', - 'idx_failed_transactions', - 'idx_analysis_merchant_chain_date', - 'idx_analysis_language_status_date', - 'idx_analysis_savings_date', - 'idx_merchant_status_plan_date', - 'idx_merchant_last_active', - 'idx_chain_status_type_date', - 'idx_chain_reliability_date', - 'idx_transaction_covering', - 'idx_analysis_covering' - ]; + // Create unique constraints + await queryRunner.query( + `ALTER TABLE "merchants" ADD CONSTRAINT "UQ_merchant_name" UNIQUE ("name")`, + ); + await queryRunner.query( + `ALTER TABLE "merchants" ADD CONSTRAINT "UQ_merchant_slug" UNIQUE ("slug")`, + ); + await queryRunner.query( + `ALTER TABLE "chains" ADD CONSTRAINT "UQ_chain_name" UNIQUE ("name")`, + ); + await queryRunner.query( + `ALTER TABLE "chains" ADD CONSTRAINT "UQ_chain_id" UNIQUE ("chain_id")`, + ); + } - for (const indexName of optimizedIndexes) { - try { - await queryRunner.query(`DROP INDEX IF EXISTS ${indexName}`); - } catch (error) { - // Index might not exist, continue - } - } + public async down(queryRunner: QueryRunner): Promise { + // Drop optimized indexes first + const optimizedIndexes = [ + "idx_merchant_chain_date", + "idx_merchant_status_date", + "idx_merchant_gas_date", + "idx_chain_status_date", + "idx_chain_gas_date", + "idx_chain_merchant_date", + "idx_recent_transactions", + "idx_high_gas_transactions", + "idx_failed_transactions", + "idx_analysis_merchant_chain_date", + "idx_analysis_language_status_date", + "idx_analysis_savings_date", + "idx_merchant_status_plan_date", + "idx_merchant_last_active", + "idx_chain_status_type_date", + "idx_chain_reliability_date", + "idx_transaction_covering", + "idx_analysis_covering", + ]; - // Drop tables - await queryRunner.query(`DROP TABLE "analysis_results"`); - await queryRunner.query(`DROP TABLE "chains"`); - await queryRunner.query(`DROP TABLE "merchants"`); - await queryRunner.query(`DROP TABLE "transactions"`); + for (const indexName of optimizedIndexes) { + try { + await queryRunner.query(`DROP INDEX IF EXISTS ${indexName}`); + } catch (error) { + // Index might not exist, continue + } } -} \ No newline at end of file + + // Drop tables + await queryRunner.query(`DROP TABLE "analysis_results"`); + await queryRunner.query(`DROP TABLE "chains"`); + await queryRunner.query(`DROP TABLE "merchants"`); + await queryRunner.query(`DROP TABLE "transactions"`); + } +} diff --git a/apps/api-service/src/database/migrations/1708480001000-CreateAuditLogTables.ts b/apps/api-service/src/database/migrations/1708480001000-CreateAuditLogTables.ts index c4e1dd7..e47fff2 100644 --- a/apps/api-service/src/database/migrations/1708480001000-CreateAuditLogTables.ts +++ b/apps/api-service/src/database/migrations/1708480001000-CreateAuditLogTables.ts @@ -25,7 +25,12 @@ interface Table { interface QueryRunner { createTable(table: Table): Promise; - createIndex(tableName: string, indexName: string, columnNames: string[], isUnique?: boolean): Promise; + createIndex( + tableName: string, + indexName: string, + columnNames: string[], + isUnique?: boolean, + ): Promise; dropTable(tableName: string): Promise; } @@ -38,193 +43,214 @@ export class CreateAuditLogTables1708480001000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Create audit_logs table await queryRunner.createTable({ - name: 'audit_logs', + name: "audit_logs", columns: [ { - name: 'id', - type: 'uuid', + name: "id", + type: "uuid", isPrimary: true, - default: 'gen_random_uuid()', + default: "gen_random_uuid()", }, { - name: 'eventType', - type: 'enum', - enum: ['APIRequest', 'KeyCreated', 'KeyRotated', 'KeyRevoked', 'GasTransaction', 'GasSubmission'], + name: "eventType", + type: "enum", + enum: [ + "APIRequest", + "KeyCreated", + "KeyRotated", + "KeyRevoked", + "GasTransaction", + "GasSubmission", + ], }, { - name: 'timestamp', - type: 'timestamp', + name: "timestamp", + type: "timestamp", }, { - name: 'user', - type: 'varchar', - length: '255', + name: "user", + type: "varchar", + length: "255", isNullable: true, }, { - name: 'apiKey', - type: 'varchar', - length: '255', + name: "apiKey", + type: "varchar", + length: "255", isNullable: true, }, { - name: 'chainId', - type: 'integer', + name: "chainId", + type: "integer", isNullable: true, }, { - name: 'details', - type: 'jsonb', + name: "details", + type: "jsonb", }, { - name: 'outcome', - type: 'enum', - enum: ['success', 'failure', 'warning'], + name: "outcome", + type: "enum", + enum: ["success", "failure", "warning"], }, { - name: 'endpoint', - type: 'varchar', - length: '255', + name: "endpoint", + type: "varchar", + length: "255", isNullable: true, }, { - name: 'httpMethod', - type: 'varchar', - length: '10', + name: "httpMethod", + type: "varchar", + length: "10", isNullable: true, }, { - name: 'responseStatus', - type: 'integer', + name: "responseStatus", + type: "integer", isNullable: true, }, { - name: 'ipAddress', - type: 'varchar', - length: '255', + name: "ipAddress", + type: "varchar", + length: "255", isNullable: true, }, { - name: 'errorMessage', - type: 'text', + name: "errorMessage", + type: "text", isNullable: true, }, { - name: 'responseDuration', - type: 'bigint', + name: "responseDuration", + type: "bigint", isNullable: true, }, { - name: 'integrity', - type: 'varchar', - length: '64', + name: "integrity", + type: "varchar", + length: "64", isNullable: true, }, { - name: 'createdAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', + name: "createdAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", }, ], } as unknown as Table); // Create indexes for efficient queries - await queryRunner.createIndex('audit_logs', 'idx_audit_event_type', ['eventType']); - await queryRunner.createIndex('audit_logs', 'idx_audit_user', ['user']); - await queryRunner.createIndex('audit_logs', 'idx_audit_timestamp', ['timestamp']); - await queryRunner.createIndex('audit_logs', 'idx_audit_chain_id', ['chainId']); - await queryRunner.createIndex('audit_logs', 'idx_audit_composite', ['eventType', 'user', 'timestamp']); + await queryRunner.createIndex("audit_logs", "idx_audit_event_type", [ + "eventType", + ]); + await queryRunner.createIndex("audit_logs", "idx_audit_user", ["user"]); + await queryRunner.createIndex("audit_logs", "idx_audit_timestamp", [ + "timestamp", + ]); + await queryRunner.createIndex("audit_logs", "idx_audit_chain_id", [ + "chainId", + ]); + await queryRunner.createIndex("audit_logs", "idx_audit_composite", [ + "eventType", + "user", + "timestamp", + ]); // Create api_keys table await queryRunner.createTable({ - name: 'api_keys', + name: "api_keys", columns: [ { - name: 'id', - type: 'uuid', + name: "id", + type: "uuid", isPrimary: true, - default: 'gen_random_uuid()', + default: "gen_random_uuid()", }, { - name: 'merchantId', - type: 'varchar', - length: '100', + name: "merchantId", + type: "varchar", + length: "100", }, { - name: 'name', - type: 'varchar', - length: '255', + name: "name", + type: "varchar", + length: "255", }, { - name: 'keyHash', - type: 'varchar', - length: '255', + name: "keyHash", + type: "varchar", + length: "255", }, { - name: 'status', - type: 'enum', - enum: ['active', 'rotated', 'revoked', 'expired'], + name: "status", + type: "enum", + enum: ["active", "rotated", "revoked", "expired"], default: "'active'", }, { - name: 'lastUsedAt', - type: 'timestamp', + name: "lastUsedAt", + type: "timestamp", isNullable: true, }, { - name: 'requestCount', - type: 'integer', + name: "requestCount", + type: "integer", default: 0, }, { - name: 'expiresAt', - type: 'timestamp', + name: "expiresAt", + type: "timestamp", isNullable: true, }, { - name: 'description', - type: 'text', + name: "description", + type: "text", isNullable: true, }, { - name: 'role', - type: 'varchar', - length: '50', + name: "role", + type: "varchar", + length: "50", default: "'user'", }, { - name: 'metadata', - type: 'jsonb', + name: "metadata", + type: "jsonb", isNullable: true, }, { - name: 'rotatedFromId', - type: 'uuid', + name: "rotatedFromId", + type: "uuid", isNullable: true, }, { - name: 'createdAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', + name: "createdAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", }, { - name: 'updatedAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', - onUpdate: 'CURRENT_TIMESTAMP', + name: "updatedAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", + onUpdate: "CURRENT_TIMESTAMP", }, ], } as unknown as Table); // Create indexes for api_keys - await queryRunner.createIndex('api_keys', 'idx_apikey_hash', ['keyHash']); - await queryRunner.createIndex('api_keys', 'idx_apikey_merchant', ['merchantId']); - await queryRunner.createIndex('api_keys', 'idx_apikey_status', ['status']); - await queryRunner.createIndex('api_keys', 'idx_apikey_created', ['createdAt']); + await queryRunner.createIndex("api_keys", "idx_apikey_hash", ["keyHash"]); + await queryRunner.createIndex("api_keys", "idx_apikey_merchant", [ + "merchantId", + ]); + await queryRunner.createIndex("api_keys", "idx_apikey_status", ["status"]); + await queryRunner.createIndex("api_keys", "idx_apikey_created", [ + "createdAt", + ]); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable('api_keys'); - await queryRunner.dropTable('audit_logs'); + await queryRunner.dropTable("api_keys"); + await queryRunner.dropTable("audit_logs"); } } diff --git a/apps/api-service/src/database/migrations/1708480002000-OptimizeEventIndexing.ts b/apps/api-service/src/database/migrations/1708480002000-OptimizeEventIndexing.ts index c60e48a..787fa92 100644 --- a/apps/api-service/src/database/migrations/1708480002000-OptimizeEventIndexing.ts +++ b/apps/api-service/src/database/migrations/1708480002000-OptimizeEventIndexing.ts @@ -1,24 +1,36 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class OptimizeEventIndexing1708480002000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - // Add more indexes for audit_logs for Issue #106 - await queryRunner.query(`CREATE INDEX "idx_audit_outcome" ON "audit_logs" ("outcome")`); - await queryRunner.query(`CREATE INDEX "idx_audit_api_key" ON "audit_logs" ("apiKey")`); - await queryRunner.query(`CREATE INDEX "idx_audit_endpoint" ON "audit_logs" ("endpoint")`); - - // Add indexes for transactions too - await queryRunner.query(`CREATE INDEX "idx_transactions_status" ON "transactions" ("status")`); - await queryRunner.query(`CREATE INDEX "idx_transactions_type" ON "transactions" ("type")`); - await queryRunner.query(`CREATE INDEX "idx_transactions_chain_id" ON "transactions" ("chain_id")`); - } + public async up(queryRunner: QueryRunner): Promise { + // Add more indexes for audit_logs for Issue #106 + await queryRunner.query( + `CREATE INDEX "idx_audit_outcome" ON "audit_logs" ("outcome")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_audit_api_key" ON "audit_logs" ("apiKey")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_audit_endpoint" ON "audit_logs" ("endpoint")`, + ); - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "idx_audit_outcome"`); - await queryRunner.query(`DROP INDEX "idx_audit_api_key"`); - await queryRunner.query(`DROP INDEX "idx_audit_endpoint"`); - await queryRunner.query(`DROP INDEX "idx_transactions_status"`); - await queryRunner.query(`DROP INDEX "idx_transactions_type"`); - await queryRunner.query(`DROP INDEX "idx_transactions_chain_id"`); - } + // Add indexes for transactions too + await queryRunner.query( + `CREATE INDEX "idx_transactions_status" ON "transactions" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_transactions_type" ON "transactions" ("type")`, + ); + await queryRunner.query( + `CREATE INDEX "idx_transactions_chain_id" ON "transactions" ("chain_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "idx_audit_outcome"`); + await queryRunner.query(`DROP INDEX "idx_audit_api_key"`); + await queryRunner.query(`DROP INDEX "idx_audit_endpoint"`); + await queryRunner.query(`DROP INDEX "idx_transactions_status"`); + await queryRunner.query(`DROP INDEX "idx_transactions_type"`); + await queryRunner.query(`DROP INDEX "idx_transactions_chain_id"`); + } } diff --git a/apps/api-service/src/database/migrations/CreateUsersTable.ts b/apps/api-service/src/database/migrations/CreateUsersTable.ts index efaa875..1affab1 100644 --- a/apps/api-service/src/database/migrations/CreateUsersTable.ts +++ b/apps/api-service/src/database/migrations/CreateUsersTable.ts @@ -1,10 +1,16 @@ -import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm'; +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; /** * Migration to create users table for RBAC system */ export class CreateUsersTable implements MigrationInterface { - name = 'CreateUsersTable'; + name = "CreateUsersTable"; public async up(queryRunner: QueryRunner): Promise { // Create enum type for user roles @@ -19,103 +25,103 @@ export class CreateUsersTable implements MigrationInterface { // Create users table await queryRunner.createTable( new Table({ - name: 'users', + name: "users", columns: [ { - name: 'id', - type: 'uuid', + name: "id", + type: "uuid", isPrimary: true, - generationStrategy: 'uuid', - default: 'uuid_generate_v4()', + generationStrategy: "uuid", + default: "uuid_generate_v4()", }, { - name: 'email', - type: 'varchar', - length: '100', + name: "email", + type: "varchar", + length: "100", isNullable: false, }, { - name: 'firstName', - type: 'varchar', - length: '100', + name: "firstName", + type: "varchar", + length: "100", isNullable: true, }, { - name: 'lastName', - type: 'varchar', - length: '100', + name: "lastName", + type: "varchar", + length: "100", isNullable: true, }, { - name: 'passwordHash', - type: 'varchar', - length: '255', + name: "passwordHash", + type: "varchar", + length: "255", isNullable: false, }, { - name: 'role', - type: 'user_role_enum', + name: "role", + type: "user_role_enum", default: "'viewer'", isNullable: false, }, { - name: 'merchantId', - type: 'uuid', + name: "merchantId", + type: "uuid", isNullable: true, }, { - name: 'isActive', - type: 'boolean', + name: "isActive", + type: "boolean", default: true, isNullable: false, }, { - name: 'lastLoginAt', - type: 'timestamp', + name: "lastLoginAt", + type: "timestamp", isNullable: true, }, { - name: 'lastLoginIp', - type: 'varchar', - length: '255', + name: "lastLoginIp", + type: "varchar", + length: "255", isNullable: true, }, { - name: 'passwordChangedAt', - type: 'timestamp', + name: "passwordChangedAt", + type: "timestamp", isNullable: true, }, { - name: 'failedLoginAttempts', - type: 'integer', + name: "failedLoginAttempts", + type: "integer", default: 0, isNullable: false, }, { - name: 'lockedUntil', - type: 'timestamp', + name: "lockedUntil", + type: "timestamp", isNullable: true, }, { - name: 'createdAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', + name: "createdAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", isNullable: false, }, { - name: 'updatedAt', - type: 'timestamp', - default: 'CURRENT_TIMESTAMP', + name: "updatedAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", isNullable: false, }, { - name: 'createdBy', - type: 'uuid', + name: "createdBy", + type: "uuid", isNullable: true, }, { - name: 'metadata', - type: 'jsonb', + name: "metadata", + type: "jsonb", isNullable: true, }, ], @@ -125,63 +131,63 @@ export class CreateUsersTable implements MigrationInterface { // Create indexes await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_email', - columnNames: ['email'], + name: "idx_user_email", + columnNames: ["email"], isUnique: true, }), ); await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_role', - columnNames: ['role'], + name: "idx_user_role", + columnNames: ["role"], }), ); await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_merchant_id', - columnNames: ['merchantId'], + name: "idx_user_merchant_id", + columnNames: ["merchantId"], }), ); await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_is_active', - columnNames: ['isActive'], + name: "idx_user_is_active", + columnNames: ["isActive"], }), ); await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_created_at', - columnNames: ['createdAt'], + name: "idx_user_created_at", + columnNames: ["createdAt"], }), ); await queryRunner.createIndex( - 'users', + "users", new TableIndex({ - name: 'idx_user_created_by', - columnNames: ['createdBy'], + name: "idx_user_created_by", + columnNames: ["createdBy"], }), ); // Create foreign key to merchants table await queryRunner.createForeignKey( - 'users', + "users", new TableForeignKey({ - name: 'fk_user_merchant', - columnNames: ['merchantId'], - referencedColumnNames: ['id'], - referencedTableName: 'merchants', - onDelete: 'SET NULL', + name: "fk_user_merchant", + columnNames: ["merchantId"], + referencedColumnNames: ["id"], + referencedTableName: "merchants", + onDelete: "SET NULL", }), ); @@ -207,17 +213,21 @@ export class CreateUsersTable implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { // Drop trigger - await queryRunner.query(`DROP TRIGGER IF EXISTS update_users_updated_at ON users;`); + await queryRunner.query( + `DROP TRIGGER IF EXISTS update_users_updated_at ON users;`, + ); // Drop foreign key - const table = await queryRunner.getTable('users'); - const foreignKey = table?.foreignKeys.find((fk: TableForeignKey) => fk.name === 'fk_user_merchant'); + const table = await queryRunner.getTable("users"); + const foreignKey = table?.foreignKeys.find( + (fk: TableForeignKey) => fk.name === "fk_user_merchant", + ); if (foreignKey) { - await queryRunner.dropForeignKey('users', foreignKey); + await queryRunner.dropForeignKey("users", foreignKey); } // Drop table - await queryRunner.dropTable('users'); + await queryRunner.dropTable("users"); // Drop enum type await queryRunner.query(`DROP TYPE IF EXISTS user_role_enum;`); diff --git a/apps/api-service/src/database/optimization/index-optimization.ts b/apps/api-service/src/database/optimization/index-optimization.ts index 3364aed..9d1ab44 100644 --- a/apps/api-service/src/database/optimization/index-optimization.ts +++ b/apps/api-service/src/database/optimization/index-optimization.ts @@ -1,8 +1,8 @@ -import { Logger } from '@nestjs/common'; -import { QueryRunner, TableColumn } from 'typeorm'; +import { Logger } from "@nestjs/common"; +import { QueryRunner, TableColumn } from "typeorm"; export class DatabaseIndexOptimization { - private static readonly logger = new Logger('DatabaseIndexOptimization'); + private static readonly logger = new Logger("DatabaseIndexOptimization"); /** * Optimize database indexes for analytics queries @@ -13,150 +13,150 @@ export class DatabaseIndexOptimization { * 4. Dashboard performance improvements */ static async applyOptimizedIndexes(queryRunner: QueryRunner): Promise { - this.logger.log('Starting database index optimization...'); + this.logger.log("Starting database index optimization..."); try { // 1. Composite indexes for merchant analytics await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_merchant_chain_date', - ['merchant_id', 'chain_id', 'created_at'] + "transactions", + "idx_merchant_chain_date", + ["merchant_id", "chain_id", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_merchant_status_date', - ['merchant_id', 'status', 'created_at'] + "transactions", + "idx_merchant_status_date", + ["merchant_id", "status", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_merchant_gas_date', - ['merchant_id', 'gas_used', 'created_at'] + "transactions", + "idx_merchant_gas_date", + ["merchant_id", "gas_used", "created_at"], ); // 2. Composite indexes for chain analytics await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_chain_status_date', - ['chain_id', 'status', 'created_at'] + "transactions", + "idx_chain_status_date", + ["chain_id", "status", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_chain_gas_date', - ['chain_id', 'gas_used', 'created_at'] + "transactions", + "idx_chain_gas_date", + ["chain_id", "gas_used", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'transactions', - 'idx_chain_merchant_date', - ['chain_id', 'merchant_id', 'created_at'] + "transactions", + "idx_chain_merchant_date", + ["chain_id", "merchant_id", "created_at"], ); // 3. Partial indexes for recent/frequent data await this.createPartialIndexIfNotExists( queryRunner, - 'transactions', - 'idx_recent_transactions', - ['created_at', 'status'], - "created_at > NOW() - INTERVAL '30 days' AND status = 'success'" + "transactions", + "idx_recent_transactions", + ["created_at", "status"], + "created_at > NOW() - INTERVAL '30 days' AND status = 'success'", ); await this.createPartialIndexIfNotExists( queryRunner, - 'transactions', - 'idx_high_gas_transactions', - ['gas_used', 'created_at'], - 'gas_used > 1000000' // High gas usage threshold + "transactions", + "idx_high_gas_transactions", + ["gas_used", "created_at"], + "gas_used > 1000000", // High gas usage threshold ); await this.createPartialIndexIfNotExists( queryRunner, - 'transactions', - 'idx_failed_transactions', - ['created_at', 'error_message'], - "status = 'failed' AND error_message IS NOT NULL" + "transactions", + "idx_failed_transactions", + ["created_at", "error_message"], + "status = 'failed' AND error_message IS NOT NULL", ); // 4. Indexes for analysis results await this.createIndexIfNotExists( queryRunner, - 'analysis_results', - 'idx_analysis_merchant_chain_date', - ['merchant_id', 'chain_id', 'created_at'] + "analysis_results", + "idx_analysis_merchant_chain_date", + ["merchant_id", "chain_id", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'analysis_results', - 'idx_analysis_language_status_date', - ['language', 'status', 'created_at'] + "analysis_results", + "idx_analysis_language_status_date", + ["language", "status", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'analysis_results', - 'idx_analysis_savings_date', - ['estimated_gas_savings', 'created_at'] + "analysis_results", + "idx_analysis_savings_date", + ["estimated_gas_savings", "created_at"], ); // 5. Indexes for merchant analytics await this.createIndexIfNotExists( queryRunner, - 'merchants', - 'idx_merchant_status_plan_date', - ['status', 'plan', 'created_at'] + "merchants", + "idx_merchant_status_plan_date", + ["status", "plan", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'merchants', - 'idx_merchant_last_active', - ['last_active_at', 'status'] + "merchants", + "idx_merchant_last_active", + ["last_active_at", "status"], ); // 6. Indexes for chain analytics await this.createIndexIfNotExists( queryRunner, - 'chains', - 'idx_chain_status_type_date', - ['status', 'type', 'created_at'] + "chains", + "idx_chain_status_type_date", + ["status", "type", "created_at"], ); await this.createIndexIfNotExists( queryRunner, - 'chains', - 'idx_chain_reliability_date', - ['reliability_score', 'created_at'] + "chains", + "idx_chain_reliability_date", + ["reliability_score", "created_at"], ); // 7. Covering indexes for common query patterns await this.createCoveringIndexIfNotExists( queryRunner, - 'transactions', - 'idx_transaction_covering', - ['merchant_id', 'chain_id', 'status', 'created_at'], - ['gas_used', 'transaction_fee', 'contract_address'] + "transactions", + "idx_transaction_covering", + ["merchant_id", "chain_id", "status", "created_at"], + ["gas_used", "transaction_fee", "contract_address"], ); await this.createCoveringIndexIfNotExists( queryRunner, - 'analysis_results', - 'idx_analysis_covering', - ['merchant_id', 'chain_id', 'status', 'created_at'], - ['violation_count', 'estimated_gas_savings', 'language'] + "analysis_results", + "idx_analysis_covering", + ["merchant_id", "chain_id", "status", "created_at"], + ["violation_count", "estimated_gas_savings", "language"], ); - this.logger.log('Database index optimization completed successfully'); + this.logger.log("Database index optimization completed successfully"); } catch (error) { - this.logger.error('Failed to apply database index optimization', error); + this.logger.error("Failed to apply database index optimization", error); throw error; } } @@ -168,11 +168,11 @@ export class DatabaseIndexOptimization { queryRunner: QueryRunner, tableName: string, indexName: string, - columns: string[] + columns: string[], ): Promise { - const columnList = columns.join(', '); + const columnList = columns.join(", "); const query = `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columnList})`; - + try { await queryRunner.query(query); this.logger.log(`Created index: ${indexName} on ${tableName}`); @@ -189,14 +189,16 @@ export class DatabaseIndexOptimization { tableName: string, indexName: string, columns: string[], - condition: string + condition: string, ): Promise { - const columnList = columns.join(', '); + const columnList = columns.join(", "); const query = `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columnList}) WHERE ${condition}`; - + try { await queryRunner.query(query); - this.logger.log(`Created partial index: ${indexName} on ${tableName} with condition: ${condition}`); + this.logger.log( + `Created partial index: ${indexName} on ${tableName} with condition: ${condition}`, + ); } catch (error) { this.logger.error(`Failed to create partial index ${indexName}:`, error); } @@ -210,12 +212,12 @@ export class DatabaseIndexOptimization { tableName: string, indexName: string, indexedColumns: string[], - includedColumns: string[] + includedColumns: string[], ): Promise { - const indexedColumnList = indexedColumns.join(', '); - const includedColumnList = includedColumns.join(', '); + const indexedColumnList = indexedColumns.join(", "); + const includedColumnList = includedColumns.join(", "); const query = `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${indexedColumnList}) INCLUDE (${includedColumnList})`; - + try { await queryRunner.query(query); this.logger.log(`Created covering index: ${indexName} on ${tableName}`); @@ -226,7 +228,10 @@ export class DatabaseIndexOptimization { await queryRunner.query(fallbackQuery); this.logger.log(`Created fallback index: ${indexName} on ${tableName}`); } catch (fallbackError) { - this.logger.error(`Failed to create covering index ${indexName}:`, fallbackError); + this.logger.error( + `Failed to create covering index ${indexName}:`, + fallbackError, + ); } } } @@ -234,12 +239,14 @@ export class DatabaseIndexOptimization { /** * Analyze and optimize existing indexes */ - static async analyzeIndexPerformance(queryRunner: QueryRunner): Promise { - this.logger.log('Analyzing index performance...'); + static async analyzeIndexPerformance( + queryRunner: QueryRunner, + ): Promise { + this.logger.log("Analyzing index performance..."); try { // Update table statistics - await queryRunner.query('ANALYZE'); + await queryRunner.query("ANALYZE"); // Get index usage statistics const indexStats = await queryRunner.query(` @@ -256,7 +263,7 @@ export class DatabaseIndexOptimization { ORDER BY idx_scan DESC `); - this.logger.log('Index usage statistics:', indexStats); + this.logger.log("Index usage statistics:", indexStats); // Identify unused indexes const unusedIndexes = await queryRunner.query(` @@ -270,19 +277,20 @@ export class DatabaseIndexOptimization { `); if (unusedIndexes.length > 0) { - this.logger.warn('Unused indexes found:', unusedIndexes); + this.logger.warn("Unused indexes found:", unusedIndexes); } - } catch (error) { - this.logger.error('Failed to analyze index performance:', error); + this.logger.error("Failed to analyze index performance:", error); } } /** * Monitor slow queries and suggest index improvements */ - static async monitorQueryPerformance(queryRunner: QueryRunner): Promise { - this.logger.log('Monitoring query performance...'); + static async monitorQueryPerformance( + queryRunner: QueryRunner, + ): Promise { + this.logger.log("Monitoring query performance..."); try { // Get slow queries from pg_stat_statements (if available) @@ -301,12 +309,11 @@ export class DatabaseIndexOptimization { `); if (slowQueries.length > 0) { - this.logger.warn('Slow queries detected:', slowQueries); + this.logger.warn("Slow queries detected:", slowQueries); // Here you could implement automatic index suggestions based on query patterns } - } catch (error) { - this.logger.error('Failed to monitor query performance:', error); + this.logger.error("Failed to monitor query performance:", error); } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/repositories/analysis-result.repository.ts b/apps/api-service/src/database/repositories/analysis-result.repository.ts index 1c673fe..8ed3a74 100644 --- a/apps/api-service/src/database/repositories/analysis-result.repository.ts +++ b/apps/api-service/src/database/repositories/analysis-result.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { AnalysisResult } from '../entities/analysis-result.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { AnalysisResult } from "../entities/analysis-result.entity"; @EntityRepository(AnalysisResult) export class AnalysisResultRepository extends Repository { @@ -10,25 +10,25 @@ export class AnalysisResultRepository extends Repository { merchantId?: string, chainId?: string, startDate?: Date, - endDate?: Date + endDate?: Date, ): Promise { - const query = this.createQueryBuilder('analysis') - .select('COUNT(analysis.id)', 'totalAnalyses') - .addSelect('AVG(analysis.violationCount)', 'avgViolations') - .addSelect('SUM(analysis.violationCount)', 'totalViolations') - .addSelect('AVG(analysis.estimatedGasSavings)', 'avgGasSavings') - .addSelect('SUM(analysis.estimatedGasSavings)', 'totalGasSavings'); + const query = this.createQueryBuilder("analysis") + .select("COUNT(analysis.id)", "totalAnalyses") + .addSelect("AVG(analysis.violationCount)", "avgViolations") + .addSelect("SUM(analysis.violationCount)", "totalViolations") + .addSelect("AVG(analysis.estimatedGasSavings)", "avgGasSavings") + .addSelect("SUM(analysis.estimatedGasSavings)", "totalGasSavings"); if (merchantId) { - query.andWhere('analysis.merchantId = :merchantId', { merchantId }); + query.andWhere("analysis.merchantId = :merchantId", { merchantId }); } if (chainId) { - query.andWhere('analysis.chainId = :chainId', { chainId }); + query.andWhere("analysis.chainId = :chainId", { chainId }); } if (startDate && endDate) { - query.andWhere('analysis.createdAt BETWEEN :startDate AND :endDate', { + query.andWhere("analysis.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }); @@ -43,23 +43,23 @@ export class AnalysisResultRepository extends Repository { async getTopRuleViolations( limit: number = 10, startDate?: Date, - endDate?: Date + endDate?: Date, ): Promise { - const query = this.createQueryBuilder('analysis') - .select("violation->>'ruleName'", 'ruleName') - .addSelect('COUNT(*)', 'violationCount') - .addSelect('SUM(analysis.estimatedGasSavings)', 'totalGasSavings') + const query = this.createQueryBuilder("analysis") + .select("violation->>'ruleName'", "ruleName") + .addSelect("COUNT(*)", "violationCount") + .addSelect("SUM(analysis.estimatedGasSavings)", "totalGasSavings") .leftJoin( - 'jsonb_array_elements(analysis.findings)', - 'violation', - "violation->>'ruleName' IS NOT NULL" + "jsonb_array_elements(analysis.findings)", + "violation", + "violation->>'ruleName' IS NOT NULL", ) .groupBy("violation->>'ruleName'") - .orderBy('violationCount', 'DESC') + .orderBy("violationCount", "DESC") .limit(limit); if (startDate && endDate) { - query.andWhere('analysis.createdAt BETWEEN :startDate AND :endDate', { + query.andWhere("analysis.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }); @@ -73,18 +73,18 @@ export class AnalysisResultRepository extends Repository { */ async getLanguageDistribution( startDate?: Date, - endDate?: Date + endDate?: Date, ): Promise { - const query = this.createQueryBuilder('analysis') - .select('analysis.language', 'language') - .addSelect('COUNT(analysis.id)', 'analysisCount') - .addSelect('AVG(analysis.violationCount)', 'avgViolations') - .addSelect('SUM(analysis.estimatedGasSavings)', 'totalGasSavings') - .groupBy('analysis.language') - .orderBy('analysisCount', 'DESC'); + const query = this.createQueryBuilder("analysis") + .select("analysis.language", "language") + .addSelect("COUNT(analysis.id)", "analysisCount") + .addSelect("AVG(analysis.violationCount)", "avgViolations") + .addSelect("SUM(analysis.estimatedGasSavings)", "totalGasSavings") + .groupBy("analysis.language") + .orderBy("analysisCount", "DESC"); if (startDate && endDate) { - query.andWhere('analysis.createdAt BETWEEN :startDate AND :endDate', { + query.andWhere("analysis.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }); @@ -96,20 +96,18 @@ export class AnalysisResultRepository extends Repository { /** * Get analysis trend over time */ - async getAnalysisTrend( - days: number = 30 - ): Promise { + async getAnalysisTrend(days: number = 30): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); - return this.createQueryBuilder('analysis') - .select("DATE_TRUNC('day', analysis.createdAt)", 'date') - .addSelect('COUNT(analysis.id)', 'analysisCount') - .addSelect('AVG(analysis.violationCount)', 'avgViolations') - .addSelect('SUM(analysis.estimatedGasSavings)', 'dailyGasSavings') - .where('analysis.createdAt >= :cutoffDate', { cutoffDate }) + return this.createQueryBuilder("analysis") + .select("DATE_TRUNC('day', analysis.createdAt)", "date") + .addSelect("COUNT(analysis.id)", "analysisCount") + .addSelect("AVG(analysis.violationCount)", "avgViolations") + .addSelect("SUM(analysis.estimatedGasSavings)", "dailyGasSavings") + .where("analysis.createdAt >= :cutoffDate", { cutoffDate }) .groupBy("DATE_TRUNC('day', analysis.createdAt)") - .orderBy('date', 'ASC') + .orderBy("date", "ASC") .getRawMany(); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/repositories/chain.repository.ts b/apps/api-service/src/database/repositories/chain.repository.ts index 3ca523a..708cab8 100644 --- a/apps/api-service/src/database/repositories/chain.repository.ts +++ b/apps/api-service/src/database/repositories/chain.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { Chain } from '../entities/chain.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { Chain } from "../entities/chain.entity"; @EntityRepository(Chain) export class ChainRepository extends Repository { @@ -8,56 +8,62 @@ export class ChainRepository extends Repository { */ async getChainReliabilityMetrics( startDate: Date, - endDate: Date + endDate: Date, ): Promise { - return this.createQueryBuilder('chain') - .select('chain.id', 'chainId') - .addSelect('chain.name', 'chainName') - .addSelect('chain.type', 'chainType') - .addSelect('chain.reliabilityScore', 'reliabilityScore') - .addSelect('chain.averageGasPrice', 'averageGasPrice') - .addSelect('chain.gasVolatility', 'gasVolatility') - .addSelect('chain.transactionCount', 'totalTransactions') - .addSelect('COUNT(transaction.id)', 'recentTransactions') + return this.createQueryBuilder("chain") + .select("chain.id", "chainId") + .addSelect("chain.name", "chainName") + .addSelect("chain.type", "chainType") + .addSelect("chain.reliabilityScore", "reliabilityScore") + .addSelect("chain.averageGasPrice", "averageGasPrice") + .addSelect("chain.gasVolatility", "gasVolatility") + .addSelect("chain.transactionCount", "totalTransactions") + .addSelect("COUNT(transaction.id)", "recentTransactions") .addSelect( "COUNT(CASE WHEN transaction.status = 'success' THEN 1 END) * 100.0 / COUNT(transaction.id)", - 'successRate' + "successRate", ) - .leftJoin('transaction', 'transaction', 'transaction.chainId = chain.chainId') - .where('transaction.createdAt BETWEEN :startDate AND :endDate', { + .leftJoin( + "transaction", + "transaction", + "transaction.chainId = chain.chainId", + ) + .where("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) .groupBy( - 'chain.id, chain.name, chain.type, chain.reliabilityScore, chain.averageGasPrice, chain.gasVolatility, chain.transactionCount' + "chain.id, chain.name, chain.type, chain.reliabilityScore, chain.averageGasPrice, chain.gasVolatility, chain.transactionCount", ) - .orderBy('chain.reliabilityScore', 'DESC') + .orderBy("chain.reliabilityScore", "DESC") .getRawMany(); } /** * Get gas volatility metrics */ - async getGasVolatilityMetrics( - days: number = 30 - ): Promise { + async getGasVolatilityMetrics(days: number = 30): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); - return this.createQueryBuilder('chain') - .select('chain.chainId', 'chainId') - .addSelect('chain.name', 'chainName') - .addSelect('STDDEV(transaction.gasUsed)', 'gasVolatility') - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') - .addSelect('MIN(transaction.gasUsed)', 'minGasUsed') - .addSelect('MAX(transaction.gasUsed)', 'maxGasUsed') - .addSelect('COUNT(transaction.id)', 'transactionCount') - .leftJoin('transaction', 'transaction', 'transaction.chainId = chain.chainId') - .where('transaction.createdAt >= :cutoffDate', { cutoffDate }) + return this.createQueryBuilder("chain") + .select("chain.chainId", "chainId") + .addSelect("chain.name", "chainName") + .addSelect("STDDEV(transaction.gasUsed)", "gasVolatility") + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") + .addSelect("MIN(transaction.gasUsed)", "minGasUsed") + .addSelect("MAX(transaction.gasUsed)", "maxGasUsed") + .addSelect("COUNT(transaction.id)", "transactionCount") + .leftJoin( + "transaction", + "transaction", + "transaction.chainId = chain.chainId", + ) + .where("transaction.createdAt >= :cutoffDate", { cutoffDate }) .andWhere("transaction.status = 'success'") - .groupBy('chain.chainId, chain.name') - .having('COUNT(transaction.id) > 100') // Only include chains with sufficient data - .orderBy('gasVolatility', 'DESC') + .groupBy("chain.chainId, chain.name") + .having("COUNT(transaction.id) > 100") // Only include chains with sufficient data + .orderBy("gasVolatility", "DESC") .getRawMany(); } @@ -65,16 +71,16 @@ export class ChainRepository extends Repository { * Get chain performance ranking */ async getChainPerformanceRanking(): Promise { - return this.createQueryBuilder('chain') - .select('chain.chainId', 'chainId') - .addSelect('chain.name', 'chainName') - .addSelect('chain.type', 'chainType') - .addSelect('chain.reliabilityScore', 'reliabilityScore') - .addSelect('chain.averageGasPrice', 'averageGasPrice') - .addSelect('chain.transactionCount', 'totalTransactions') - .addSelect('chain.gasVolatility', 'gasVolatility') - .orderBy('chain.reliabilityScore', 'DESC') - .addOrderBy('chain.transactionCount', 'DESC') + return this.createQueryBuilder("chain") + .select("chain.chainId", "chainId") + .addSelect("chain.name", "chainName") + .addSelect("chain.type", "chainType") + .addSelect("chain.reliabilityScore", "reliabilityScore") + .addSelect("chain.averageGasPrice", "averageGasPrice") + .addSelect("chain.transactionCount", "totalTransactions") + .addSelect("chain.gasVolatility", "gasVolatility") + .orderBy("chain.reliabilityScore", "DESC") + .addOrderBy("chain.transactionCount", "DESC") .getRawMany(); } @@ -82,22 +88,26 @@ export class ChainRepository extends Repository { * Update chain metrics from transaction data */ async updateChainMetrics(chainId: string): Promise { - const result = await this.createQueryBuilder('chain') - .select('AVG(transaction.gasUsed)', 'avgGasPrice') - .addSelect('STDDEV(transaction.gasUsed)', 'gasVolatility') - .addSelect('COUNT(transaction.id)', 'transactionCount') + const result = await this.createQueryBuilder("chain") + .select("AVG(transaction.gasUsed)", "avgGasPrice") + .addSelect("STDDEV(transaction.gasUsed)", "gasVolatility") + .addSelect("COUNT(transaction.id)", "transactionCount") .addSelect( "COUNT(CASE WHEN transaction.status = 'success' THEN 1 END) * 100.0 / COUNT(transaction.id)", - 'successRate' + "successRate", + ) + .leftJoin( + "transaction", + "transaction", + "transaction.chainId = chain.chainId", ) - .leftJoin('transaction', 'transaction', 'transaction.chainId = chain.chainId') - .where('chain.chainId = :chainId', { chainId }) + .where("chain.chainId = :chainId", { chainId }) .andWhere("transaction.status IN ('success', 'failed')") - .groupBy('chain.chainId') + .groupBy("chain.chainId") .getRawOne(); if (result) { - await this.createQueryBuilder('chain') + await this.createQueryBuilder("chain") .update() .set({ averageGasPrice: parseFloat(result.avgGasPrice), @@ -105,8 +115,8 @@ export class ChainRepository extends Repository { transactionCount: parseInt(result.transactionCount), reliabilityScore: parseFloat(result.successRate) || 100, }) - .where('chain.chainId = :chainId', { chainId }) + .where("chain.chainId = :chainId", { chainId }) .execute(); } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/repositories/index.ts b/apps/api-service/src/database/repositories/index.ts index d6c7c32..9fcee1b 100644 --- a/apps/api-service/src/database/repositories/index.ts +++ b/apps/api-service/src/database/repositories/index.ts @@ -1,4 +1,4 @@ -export * from './transaction.repository'; -export * from './merchant.repository'; -export * from './chain.repository'; -export * from './analysis-result.repository'; \ No newline at end of file +export * from "./transaction.repository"; +export * from "./merchant.repository"; +export * from "./chain.repository"; +export * from "./analysis-result.repository"; diff --git a/apps/api-service/src/database/repositories/merchant.repository.ts b/apps/api-service/src/database/repositories/merchant.repository.ts index 3180130..f7da5d3 100644 --- a/apps/api-service/src/database/repositories/merchant.repository.ts +++ b/apps/api-service/src/database/repositories/merchant.repository.ts @@ -1,57 +1,53 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { Merchant } from '../entities/merchant.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { Merchant } from "../entities/merchant.entity"; @EntityRepository(Merchant) export class MerchantRepository extends Repository { /** * Get merchant analytics summary */ - async getMerchantAnalytics( - startDate: Date, - endDate: Date - ): Promise { - return this.createQueryBuilder('merchant') - .select('merchant.id', 'merchantId') - .addSelect('merchant.name', 'merchantName') - .addSelect('merchant.plan', 'plan') - .addSelect('merchant.status', 'status') - .addSelect('COUNT(transaction.id)', 'transactionCount') - .addSelect('SUM(transaction.gasUsed)', 'totalGasUsed') - .addSelect('SUM(transaction.transactionFee)', 'totalFees') - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') - .leftJoin('transaction', 'transaction', 'transaction.merchantId = merchant.id') - .where('transaction.createdAt BETWEEN :startDate AND :endDate', { + async getMerchantAnalytics(startDate: Date, endDate: Date): Promise { + return this.createQueryBuilder("merchant") + .select("merchant.id", "merchantId") + .addSelect("merchant.name", "merchantName") + .addSelect("merchant.plan", "plan") + .addSelect("merchant.status", "status") + .addSelect("COUNT(transaction.id)", "transactionCount") + .addSelect("SUM(transaction.gasUsed)", "totalGasUsed") + .addSelect("SUM(transaction.transactionFee)", "totalFees") + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") + .leftJoin( + "transaction", + "transaction", + "transaction.merchantId = merchant.id", + ) + .where("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) - .groupBy('merchant.id, merchant.name, merchant.plan, merchant.status') - .orderBy('transactionCount', 'DESC') + .groupBy("merchant.id, merchant.name, merchant.plan, merchant.status") + .orderBy("transactionCount", "DESC") .getRawMany(); } /** * Get active merchants */ - async getActiveMerchants( - days: number = 30 - ): Promise { + async getActiveMerchants(days: number = 30): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); - return this.createQueryBuilder('merchant') - .where('merchant.status = :status', { status: 'active' }) - .andWhere('merchant.lastActiveAt >= :cutoffDate', { cutoffDate }) - .orderBy('merchant.lastActiveAt', 'DESC') + return this.createQueryBuilder("merchant") + .where("merchant.status = :status", { status: "active" }) + .andWhere("merchant.lastActiveAt >= :cutoffDate", { cutoffDate }) + .orderBy("merchant.lastActiveAt", "DESC") .getMany(); } /** * Get merchant growth statistics */ - async getMerchantGrowthStats( - startDate: Date, - endDate: Date - ): Promise { + async getMerchantGrowthStats(startDate: Date, endDate: Date): Promise { const totalMerchants = await this.count({ where: { createdAt: new Date(startDate), @@ -66,7 +62,7 @@ export class MerchantRepository extends Repository { const activeMerchants = await this.count({ where: { - status: 'active', + status: "active", }, }); @@ -74,7 +70,8 @@ export class MerchantRepository extends Repository { totalMerchants, newMerchants, activeMerchants, - growthRate: totalMerchants > 0 ? (newMerchants / totalMerchants) * 100 : 0, + growthRate: + totalMerchants > 0 ? (newMerchants / totalMerchants) * 100 : 0, }; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/repositories/transaction.repository.ts b/apps/api-service/src/database/repositories/transaction.repository.ts index 9ce41de..996ec4e 100644 --- a/apps/api-service/src/database/repositories/transaction.repository.ts +++ b/apps/api-service/src/database/repositories/transaction.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { Transaction } from '../entities/transaction.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { Transaction } from "../entities/transaction.entity"; @EntityRepository(Transaction) export class TransactionRepository extends Repository { @@ -9,21 +9,21 @@ export class TransactionRepository extends Repository { async getGasUsageByMerchant( merchantId: string, startDate: Date, - endDate: Date + endDate: Date, ): Promise { - return this.createQueryBuilder('transaction') - .select('DATE(transaction.createdAt)', 'date') - .addSelect('SUM(transaction.gasUsed)', 'totalGasUsed') - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') - .addSelect('COUNT(transaction.id)', 'transactionCount') - .where('transaction.merchantId = :merchantId', { merchantId }) - .andWhere('transaction.createdAt BETWEEN :startDate AND :endDate', { + return this.createQueryBuilder("transaction") + .select("DATE(transaction.createdAt)", "date") + .addSelect("SUM(transaction.gasUsed)", "totalGasUsed") + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") + .addSelect("COUNT(transaction.id)", "transactionCount") + .where("transaction.merchantId = :merchantId", { merchantId }) + .andWhere("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) .andWhere("transaction.status = 'success'") - .groupBy('DATE(transaction.createdAt)') - .orderBy('date', 'ASC') + .groupBy("DATE(transaction.createdAt)") + .orderBy("date", "ASC") .getRawMany(); } @@ -34,31 +34,31 @@ export class TransactionRepository extends Repository { merchantId?: string, chainId?: string, startDate?: Date, - endDate?: Date + endDate?: Date, ): Promise { - const query = this.createQueryBuilder('transaction') - .select('COUNT(transaction.id)', 'totalTransactions') + const query = this.createQueryBuilder("transaction") + .select("COUNT(transaction.id)", "totalTransactions") .addSelect( "COUNT(CASE WHEN transaction.status = 'success' THEN 1 END)", - 'successfulTransactions' + "successfulTransactions", ) .addSelect( "COUNT(CASE WHEN transaction.status = 'failed' THEN 1 END)", - 'failedTransactions' + "failedTransactions", ) - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') - .addSelect('SUM(transaction.transactionFee)', 'totalFees'); + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") + .addSelect("SUM(transaction.transactionFee)", "totalFees"); if (merchantId) { - query.andWhere('transaction.merchantId = :merchantId', { merchantId }); + query.andWhere("transaction.merchantId = :merchantId", { merchantId }); } if (chainId) { - query.andWhere('transaction.chainId = :chainId', { chainId }); + query.andWhere("transaction.chainId = :chainId", { chainId }); } if (startDate && endDate) { - query.andWhere('transaction.createdAt BETWEEN :startDate AND :endDate', { + query.andWhere("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }); @@ -72,12 +72,12 @@ export class TransactionRepository extends Repository { */ async getHighGasTransactions( limit: number = 10, - threshold: number = 1000000 + threshold: number = 1000000, ): Promise { - return this.createQueryBuilder('transaction') - .where('transaction.gasUsed > :threshold', { threshold }) + return this.createQueryBuilder("transaction") + .where("transaction.gasUsed > :threshold", { threshold }) .andWhere("transaction.status = 'success'") - .orderBy('transaction.gasUsed', 'DESC') + .orderBy("transaction.gasUsed", "DESC") .limit(limit) .getMany(); } @@ -87,20 +87,20 @@ export class TransactionRepository extends Repository { */ async getTransactionVolumeByChain( startDate: Date, - endDate: Date + endDate: Date, ): Promise { - return this.createQueryBuilder('transaction') - .select('transaction.chainId', 'chainId') - .addSelect('COUNT(transaction.id)', 'transactionCount') - .addSelect('SUM(transaction.gasUsed)', 'totalGasUsed') - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') - .addSelect('SUM(transaction.transactionFee)', 'totalFees') - .where('transaction.createdAt BETWEEN :startDate AND :endDate', { + return this.createQueryBuilder("transaction") + .select("transaction.chainId", "chainId") + .addSelect("COUNT(transaction.id)", "transactionCount") + .addSelect("SUM(transaction.gasUsed)", "totalGasUsed") + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") + .addSelect("SUM(transaction.transactionFee)", "totalFees") + .where("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) - .groupBy('transaction.chainId') - .orderBy('transactionCount', 'DESC') + .groupBy("transaction.chainId") + .orderBy("transactionCount", "DESC") .getRawMany(); } @@ -109,22 +109,22 @@ export class TransactionRepository extends Repository { */ async getFailedTransactionAnalysis( startDate: Date, - endDate: Date + endDate: Date, ): Promise { - return this.createQueryBuilder('transaction') - .select('transaction.chainId', 'chainId') - .addSelect('transaction.errorMessage', 'errorMessage') - .addSelect('COUNT(transaction.id)', 'count') - .addSelect('AVG(transaction.gasUsed)', 'avgGasUsed') + return this.createQueryBuilder("transaction") + .select("transaction.chainId", "chainId") + .addSelect("transaction.errorMessage", "errorMessage") + .addSelect("COUNT(transaction.id)", "count") + .addSelect("AVG(transaction.gasUsed)", "avgGasUsed") .where("transaction.status = 'failed'") - .andWhere('transaction.createdAt BETWEEN :startDate AND :endDate', { + .andWhere("transaction.createdAt BETWEEN :startDate AND :endDate", { startDate, endDate, }) - .andWhere('transaction.errorMessage IS NOT NULL') - .groupBy('transaction.chainId, transaction.errorMessage') - .orderBy('count', 'DESC') + .andWhere("transaction.errorMessage IS NOT NULL") + .groupBy("transaction.chainId, transaction.errorMessage") + .orderBy("count", "DESC") .limit(20) .getRawMany(); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/database/services/database-analytics.service.ts b/apps/api-service/src/database/services/database-analytics.service.ts index a22fe47..a2bc4b4 100644 --- a/apps/api-service/src/database/services/database-analytics.service.ts +++ b/apps/api-service/src/database/services/database-analytics.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { TransactionRepository } from '../repositories/transaction.repository'; -import { MerchantRepository } from '../repositories/merchant.repository'; -import { ChainRepository } from '../repositories/chain.repository'; -import { AnalysisResultRepository } from '../repositories/analysis-result.repository'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { TransactionRepository } from "../repositories/transaction.repository"; +import { MerchantRepository } from "../repositories/merchant.repository"; +import { ChainRepository } from "../repositories/chain.repository"; +import { AnalysisResultRepository } from "../repositories/analysis-result.repository"; @Injectable() export class DatabaseAnalyticsService { @@ -23,28 +23,34 @@ export class DatabaseAnalyticsService { /** * Get comprehensive dashboard analytics */ - async getDashboardAnalytics(timeRange: '24h' | '7d' | '30d' = '7d'): Promise { + async getDashboardAnalytics( + timeRange: "24h" | "7d" | "30d" = "7d", + ): Promise { const endDate = new Date(); const startDate = this.getDateFromTimeRange(timeRange, endDate); try { - const [transactionMetrics, merchantAnalytics, chainMetrics, analysisSummary] = - await Promise.all([ - this.transactionRepository.getTransactionSuccessMetrics( - undefined, - undefined, - startDate, - endDate - ), - this.merchantRepository.getMerchantAnalytics(startDate, endDate), - this.chainRepository.getChainReliabilityMetrics(startDate, endDate), - this.analysisResultRepository.getAnalysisSummary( - undefined, - undefined, - startDate, - endDate - ), - ]); + const [ + transactionMetrics, + merchantAnalytics, + chainMetrics, + analysisSummary, + ] = await Promise.all([ + this.transactionRepository.getTransactionSuccessMetrics( + undefined, + undefined, + startDate, + endDate, + ), + this.merchantRepository.getMerchantAnalytics(startDate, endDate), + this.chainRepository.getChainReliabilityMetrics(startDate, endDate), + this.analysisResultRepository.getAnalysisSummary( + undefined, + undefined, + startDate, + endDate, + ), + ]); return { timeRange, @@ -59,7 +65,7 @@ export class DatabaseAnalyticsService { updatedAt: new Date().toISOString(), }; } catch (error) { - this.logger.error('Failed to get dashboard analytics', error); + this.logger.error("Failed to get dashboard analytics", error); throw error; } } @@ -69,33 +75,37 @@ export class DatabaseAnalyticsService { */ async getMerchantAnalytics( merchantId: string, - timeRange: '24h' | '7d' | '30d' = '7d' + timeRange: "24h" | "7d" | "30d" = "7d", ): Promise { const endDate = new Date(); const startDate = this.getDateFromTimeRange(timeRange, endDate); try { - const [gasUsage, transactionMetrics, analysisSummary, highGasTransactions] = - await Promise.all([ - this.transactionRepository.getGasUsageByMerchant( - merchantId, - startDate, - endDate - ), - this.transactionRepository.getTransactionSuccessMetrics( - merchantId, - undefined, - startDate, - endDate - ), - this.analysisResultRepository.getAnalysisSummary( - merchantId, - undefined, - startDate, - endDate - ), - this.transactionRepository.getHighGasTransactions(10), - ]); + const [ + gasUsage, + transactionMetrics, + analysisSummary, + highGasTransactions, + ] = await Promise.all([ + this.transactionRepository.getGasUsageByMerchant( + merchantId, + startDate, + endDate, + ), + this.transactionRepository.getTransactionSuccessMetrics( + merchantId, + undefined, + startDate, + endDate, + ), + this.analysisResultRepository.getAnalysisSummary( + merchantId, + undefined, + startDate, + endDate, + ), + this.transactionRepository.getHighGasTransactions(10), + ]); return { merchantId, @@ -113,7 +123,7 @@ export class DatabaseAnalyticsService { } catch (error) { this.logger.error( `Failed to get merchant analytics for ${merchantId}`, - error + error, ); throw error; } @@ -124,29 +134,35 @@ export class DatabaseAnalyticsService { */ async getChainAnalytics( chainId: string, - timeRange: '24h' | '7d' | '30d' = '7d' + timeRange: "24h" | "7d" | "30d" = "7d", ): Promise { const endDate = new Date(); const startDate = this.getDateFromTimeRange(timeRange, endDate); try { - const [transactionVolume, reliabilityMetrics, gasVolatility, failedAnalysis] = - await Promise.all([ - this.transactionRepository.getTransactionVolumeByChain( - startDate, - endDate - ), - this.chainRepository.getChainReliabilityMetrics(startDate, endDate), - this.chainRepository.getGasVolatilityMetrics(30), - this.transactionRepository.getFailedTransactionAnalysis( - startDate, - endDate - ), - ]); + const [ + transactionVolume, + reliabilityMetrics, + gasVolatility, + failedAnalysis, + ] = await Promise.all([ + this.transactionRepository.getTransactionVolumeByChain( + startDate, + endDate, + ), + this.chainRepository.getChainReliabilityMetrics(startDate, endDate), + this.chainRepository.getGasVolatilityMetrics(30), + this.transactionRepository.getFailedTransactionAnalysis( + startDate, + endDate, + ), + ]); - const chainData = transactionVolume.find(t => t.chainId === chainId); - const reliabilityData = reliabilityMetrics.find(c => c.chainId === chainId); - const volatilityData = gasVolatility.find(c => c.chainId === chainId); + const chainData = transactionVolume.find((t) => t.chainId === chainId); + const reliabilityData = reliabilityMetrics.find( + (c) => c.chainId === chainId, + ); + const volatilityData = gasVolatility.find((c) => c.chainId === chainId); return { chainId, @@ -159,7 +175,7 @@ export class DatabaseAnalyticsService { reliabilityMetrics: reliabilityData, gasVolatility: volatilityData, failedTransactionAnalysis: failedAnalysis.filter( - f => f.chainId === chainId + (f) => f.chainId === chainId, ), updatedAt: new Date().toISOString(), }; @@ -173,7 +189,7 @@ export class DatabaseAnalyticsService { * Get analysis performance metrics */ async getAnalysisMetrics( - timeRange: '24h' | '7d' | '30d' = '7d' + timeRange: "24h" | "7d" | "30d" = "7d", ): Promise { const endDate = new Date(); const startDate = this.getDateFromTimeRange(timeRange, endDate); @@ -185,12 +201,16 @@ export class DatabaseAnalyticsService { undefined, undefined, startDate, - endDate + endDate, + ), + this.analysisResultRepository.getTopRuleViolations( + 10, + startDate, + endDate, ), - this.analysisResultRepository.getTopRuleViolations(10, startDate, endDate), this.analysisResultRepository.getLanguageDistribution( startDate, - endDate + endDate, ), this.analysisResultRepository.getAnalysisTrend(30), ]); @@ -208,7 +228,7 @@ export class DatabaseAnalyticsService { updatedAt: new Date().toISOString(), }; } catch (error) { - this.logger.error('Failed to get analysis metrics', error); + this.logger.error("Failed to get analysis metrics", error); throw error; } } @@ -243,7 +263,7 @@ export class DatabaseAnalyticsService { updatedAt: new Date().toISOString(), }; } catch (error) { - this.logger.error('Failed to get performance metrics', error); + this.logger.error("Failed to get performance metrics", error); throw error; } } @@ -252,21 +272,21 @@ export class DatabaseAnalyticsService { * Helper method to calculate date based on time range */ private getDateFromTimeRange( - timeRange: '24h' | '7d' | '30d', - endDate: Date + timeRange: "24h" | "7d" | "30d", + endDate: Date, ): Date { const startDate = new Date(endDate); switch (timeRange) { - case '24h': + case "24h": startDate.setHours(startDate.getHours() - 24); break; - case '7d': + case "7d": startDate.setDate(startDate.getDate() - 7); break; - case '30d': + case "30d": startDate.setDate(startDate.getDate() - 30); break; } return startDate; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/gas-estimation/__tests__/network-config.service.spec.ts b/apps/api-service/src/gas-estimation/__tests__/network-config.service.spec.ts index 8990065..1c95bbf 100644 --- a/apps/api-service/src/gas-estimation/__tests__/network-config.service.spec.ts +++ b/apps/api-service/src/gas-estimation/__tests__/network-config.service.spec.ts @@ -1,33 +1,33 @@ -import { NetworkConfigService } from '../config/network-config.service'; +import { NetworkConfigService } from "../config/network-config.service"; -describe('NetworkConfigService', () => { +describe("NetworkConfigService", () => { let service: NetworkConfigService; beforeEach(() => { service = new NetworkConfigService(); }); - it('returns supported networks from a single source of truth', () => { + it("returns supported networks from a single source of truth", () => { const networks = service.getSupportedNetworks(); expect(networks.map((network) => network.chainId)).toEqual([ - 'soroban-mainnet', - 'soroban-testnet', + "soroban-mainnet", + "soroban-testnet", ]); - expect(networks[0]).toHaveProperty('chainName'); - expect(networks[0]).toHaveProperty('baseFeePerInstruction'); + expect(networks[0]).toHaveProperty("chainName"); + expect(networks[0]).toHaveProperty("baseFeePerInstruction"); }); - it('resolves network metadata for a known chain', () => { - expect(service.getNetworkConfig('soroban-mainnet')).toMatchObject({ - chainId: 'soroban-mainnet', - chainName: 'Soroban Mainnet', + it("resolves network metadata for a known chain", () => { + expect(service.getNetworkConfig("soroban-mainnet")).toMatchObject({ + chainId: "soroban-mainnet", + chainName: "Soroban Mainnet", }); }); - it('rejects unknown chains', () => { - expect(() => service.getNetworkConfig('unknown-chain')).toThrow( - 'Unsupported chainId: unknown-chain', + it("rejects unknown chains", () => { + expect(() => service.getNetworkConfig("unknown-chain")).toThrow( + "Unsupported chainId: unknown-chain", ); }); }); diff --git a/apps/api-service/src/gas-estimation/config/network-config.service.ts b/apps/api-service/src/gas-estimation/config/network-config.service.ts index c84e03c..c412642 100644 --- a/apps/api-service/src/gas-estimation/config/network-config.service.ts +++ b/apps/api-service/src/gas-estimation/config/network-config.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; export interface GasEstimationNetworkConfig { chainId: string; @@ -15,8 +15,8 @@ export interface GasEstimationNetworkConfig { export class NetworkConfigService { private readonly networks: GasEstimationNetworkConfig[] = [ { - chainId: 'soroban-mainnet', - chainName: 'Soroban Mainnet', + chainId: "soroban-mainnet", + chainName: "Soroban Mainnet", rpcUrl: process.env.GAS_ESTIMATION_SOROBAN_MAINNET_RPC_URL || process.env.GAS_ESTIMATION_SOROBAN_RPC_URL, @@ -27,8 +27,8 @@ export class NetworkConfigService { averageBlockTimeMs: 4000, }, { - chainId: 'soroban-testnet', - chainName: 'Soroban Testnet', + chainId: "soroban-testnet", + chainName: "Soroban Testnet", rpcUrl: process.env.GAS_ESTIMATION_SOROBAN_TESTNET_RPC_URL || process.env.GAS_ESTIMATION_SOROBAN_RPC_URL, @@ -49,7 +49,9 @@ export class NetworkConfigService { } getNetworkConfig(chainId: string): GasEstimationNetworkConfig { - const network = this.networks.find((candidate) => candidate.chainId === chainId); + const network = this.networks.find( + (candidate) => candidate.chainId === chainId, + ); if (!network) { throw new Error(`Unsupported chainId: ${chainId}`); diff --git a/apps/api-service/src/gas-estimation/controllers/fee-configuration.controller.ts b/apps/api-service/src/gas-estimation/controllers/fee-configuration.controller.ts index d5c0f03..8c0628a 100644 --- a/apps/api-service/src/gas-estimation/controllers/fee-configuration.controller.ts +++ b/apps/api-service/src/gas-estimation/controllers/fee-configuration.controller.ts @@ -1,48 +1,51 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, Query, - HttpException, + HttpException, HttpStatus, - UseGuards -} from '@nestjs/common'; -import { FeeConfigurationService } from '../services/fee-configuration.service'; -import { - FeeConfiguration, - FeeUpdateRequest, + UseGuards, +} from "@nestjs/common"; +import { FeeConfigurationService } from "../services/fee-configuration.service"; +import { + FeeConfiguration, + FeeUpdateRequest, FeeChangeEvent, FeeValidationResult, - AdminFeeSettings -} from '../interfaces/fee-config.interface'; + AdminFeeSettings, +} from "../interfaces/fee-config.interface"; /** * FeeConfigurationController * Admin endpoints for managing configurable protocol fees */ -@Controller('admin/fee-configuration') +@Controller("admin/fee-configuration") @UseGuards(/* Add admin authentication guard here */) export class FeeConfigurationController { - constructor(private readonly feeConfigurationService: FeeConfigurationService) {} + constructor( + private readonly feeConfigurationService: FeeConfigurationService, + ) {} /** * Get current fee configuration */ - @Get('current') + @Get("current") async getCurrentConfiguration() { try { - const config = await this.feeConfigurationService.getCurrentConfiguration(); + const config = + await this.feeConfigurationService.getCurrentConfiguration(); return { success: true, data: config, }; } catch (error) { throw new HttpException( - error.message || 'Failed to get current configuration', + error.message || "Failed to get current configuration", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -54,14 +57,15 @@ export class FeeConfigurationController { @Get() async getAllConfigurations() { try { - const configurations = await this.feeConfigurationService.getAllConfigurations(); + const configurations = + await this.feeConfigurationService.getAllConfigurations(); return { success: true, data: configurations, }; } catch (error) { throw new HttpException( - error.message || 'Failed to get configurations', + error.message || "Failed to get configurations", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -70,19 +74,23 @@ export class FeeConfigurationController { /** * Update fee configuration */ - @Put(':configId') + @Put(":configId") async updateConfiguration( - @Param('configId') configId: string, + @Param("configId") configId: string, @Body() updateRequest: FeeUpdateRequest, ) { try { // In production, get admin user ID from authentication - const adminUserId = 'admin-user'; // Placeholder - + const adminUserId = "admin-user"; // Placeholder + // Validate the update request first - const currentConfig = await this.feeConfigurationService.getCurrentConfiguration(); - const validation = await this.validateUpdate(currentConfig, updateRequest); - + const currentConfig = + await this.feeConfigurationService.getCurrentConfiguration(); + const validation = await this.validateUpdate( + currentConfig, + updateRequest, + ); + if (!validation.isValid) { return { success: false, @@ -92,22 +100,23 @@ export class FeeConfigurationController { }; } - const configuration = await this.feeConfigurationService.updateConfiguration( - configId, - updateRequest, - adminUserId, - ); + const configuration = + await this.feeConfigurationService.updateConfiguration( + configId, + updateRequest, + adminUserId, + ); return { success: true, data: configuration, - message: 'Fee configuration updated successfully', + message: "Fee configuration updated successfully", warnings: validation.warnings, impact: validation.impact, }; } catch (error) { throw new HttpException( - error.message || 'Failed to update configuration', + error.message || "Failed to update configuration", HttpStatus.BAD_REQUEST, ); } @@ -116,153 +125,157 @@ export class FeeConfigurationController { /** * Create a multisig approval request for a large fee update */ - @Post(':configId/approval-requests') + @Post(":configId/approval-requests") async createApprovalRequest( - @Param('configId') configId: string, + @Param("configId") configId: string, @Body() updateRequest: FeeUpdateRequest, ) { try { - const adminUserId = 'admin-user'; // Placeholder - const approvalRequest = await this.feeConfigurationService.createApprovalRequest( - configId, - updateRequest, - adminUserId, - ); + const adminUserId = "admin-user"; // Placeholder + const approvalRequest = + await this.feeConfigurationService.createApprovalRequest( + configId, + updateRequest, + adminUserId, + ); return { success: true, data: approvalRequest, - message: 'Approval request created successfully', + message: "Approval request created successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to create approval request', + error.message || "Failed to create approval request", HttpStatus.BAD_REQUEST, ); } } - @Get('approval-requests') - async getApprovalRequests(@Query('configId') configId?: string) { + @Get("approval-requests") + async getApprovalRequests(@Query("configId") configId?: string) { try { - const approvalRequests = await this.feeConfigurationService.getApprovalRequests(configId); + const approvalRequests = + await this.feeConfigurationService.getApprovalRequests(configId); return { success: true, data: approvalRequests, }; } catch (error) { throw new HttpException( - error.message || 'Failed to fetch approval requests', + error.message || "Failed to fetch approval requests", HttpStatus.INTERNAL_SERVER_ERROR, ); } } - @Get('approval-requests/:requestId') - async getApprovalRequest(@Param('requestId') requestId: string) { + @Get("approval-requests/:requestId") + async getApprovalRequest(@Param("requestId") requestId: string) { try { - const approvalRequest = await this.feeConfigurationService.getApprovalRequest(requestId); + const approvalRequest = + await this.feeConfigurationService.getApprovalRequest(requestId); return { success: true, data: approvalRequest, }; } catch (error) { throw new HttpException( - error.message || 'Failed to fetch approval request', + error.message || "Failed to fetch approval request", HttpStatus.INTERNAL_SERVER_ERROR, ); } } - @Get('scheduled-updates') - async getScheduledUpdates(@Query('configId') configId?: string) { + @Get("scheduled-updates") + async getScheduledUpdates(@Query("configId") configId?: string) { try { - const scheduledUpdates = await this.feeConfigurationService.getScheduledUpdates(configId); + const scheduledUpdates = + await this.feeConfigurationService.getScheduledUpdates(configId); return { success: true, data: scheduledUpdates, }; } catch (error) { throw new HttpException( - error.message || 'Failed to fetch scheduled updates', + error.message || "Failed to fetch scheduled updates", HttpStatus.INTERNAL_SERVER_ERROR, ); } } - @Get('scheduled-updates/:updateId') - async getScheduledUpdate(@Param('updateId') updateId: string) { + @Get("scheduled-updates/:updateId") + async getScheduledUpdate(@Param("updateId") updateId: string) { try { - const scheduledUpdate = await this.feeConfigurationService.getScheduledUpdate(updateId); + const scheduledUpdate = + await this.feeConfigurationService.getScheduledUpdate(updateId); return { success: true, data: scheduledUpdate, }; } catch (error) { throw new HttpException( - error.message || 'Failed to fetch scheduled update', + error.message || "Failed to fetch scheduled update", HttpStatus.INTERNAL_SERVER_ERROR, ); } } - @Post('scheduled-updates/process') + @Post("scheduled-updates/process") async processScheduledUpdates() { try { - const executedUpdates = await this.feeConfigurationService.processPendingScheduledUpdates(); + const executedUpdates = + await this.feeConfigurationService.processPendingScheduledUpdates(); return { success: true, data: executedUpdates, - message: 'Processed pending scheduled updates', + message: "Processed pending scheduled updates", }; } catch (error) { throw new HttpException( - error.message || 'Failed to process scheduled updates', + error.message || "Failed to process scheduled updates", HttpStatus.BAD_REQUEST, ); } } - @Post('approval-requests/:requestId/approve') - async approveApprovalRequest( - @Param('requestId') requestId: string, - ) { + @Post("approval-requests/:requestId/approve") + async approveApprovalRequest(@Param("requestId") requestId: string) { try { - const adminUserId = 'admin-user'; // Placeholder - const approvalRequest = await this.feeConfigurationService.approveApprovalRequest( - requestId, - adminUserId, - ); + const adminUserId = "admin-user"; // Placeholder + const approvalRequest = + await this.feeConfigurationService.approveApprovalRequest( + requestId, + adminUserId, + ); return { success: true, data: approvalRequest, - message: 'Approval recorded successfully', + message: "Approval recorded successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to approve request', + error.message || "Failed to approve request", HttpStatus.BAD_REQUEST, ); } } - @Post('approval-requests/:requestId/reject') - async rejectApprovalRequest( - @Param('requestId') requestId: string, - ) { + @Post("approval-requests/:requestId/reject") + async rejectApprovalRequest(@Param("requestId") requestId: string) { try { - const adminUserId = 'admin-user'; // Placeholder - const approvalRequest = await this.feeConfigurationService.rejectApprovalRequest( - requestId, - adminUserId, - ); + const adminUserId = "admin-user"; // Placeholder + const approvalRequest = + await this.feeConfigurationService.rejectApprovalRequest( + requestId, + adminUserId, + ); return { success: true, data: approvalRequest, - message: 'Approval request rejected successfully', + message: "Approval request rejected successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to reject request', + error.message || "Failed to reject request", HttpStatus.BAD_REQUEST, ); } @@ -272,11 +285,17 @@ export class FeeConfigurationController { * Create new fee configuration */ @Post() - async createConfiguration(@Body() configuration: Omit) { + async createConfiguration( + @Body() + configuration: Omit< + FeeConfiguration, + "id" | "createdAt" | "updatedAt" | "version" + >, + ) { try { // In production, get admin user ID from authentication - const adminUserId = 'admin-user'; // Placeholder - + const adminUserId = "admin-user"; // Placeholder + const newConfig = await this.feeConfigurationService.createConfiguration( configuration, adminUserId, @@ -285,11 +304,11 @@ export class FeeConfigurationController { return { success: true, data: newConfig, - message: 'Fee configuration created successfully', + message: "Fee configuration created successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to create configuration', + error.message || "Failed to create configuration", HttpStatus.BAD_REQUEST, ); } @@ -298,17 +317,18 @@ export class FeeConfigurationController { /** * Get fee configuration history */ - @Get(':configId/history') - async getConfigurationHistory(@Param('configId') configId: string) { + @Get(":configId/history") + async getConfigurationHistory(@Param("configId") configId: string) { try { - const history = await this.feeConfigurationService.getConfigurationHistory(configId); + const history = + await this.feeConfigurationService.getConfigurationHistory(configId); return { success: true, data: history, }; } catch (error) { throw new HttpException( - error.message || 'Failed to get configuration history', + error.message || "Failed to get configuration history", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -317,11 +337,11 @@ export class FeeConfigurationController { /** * Get fee change events */ - @Get(':configId/events') + @Get(":configId/events") async getFeeEvents( - @Param('configId') configId: string, - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, + @Param("configId") configId: string, + @Query("startDate") startDate?: string, + @Query("endDate") endDate?: string, ) { try { const events = await this.feeConfigurationService.getFeeEvents( @@ -335,7 +355,7 @@ export class FeeConfigurationController { }; } catch (error) { throw new HttpException( - error.message || 'Failed to get fee events', + error.message || "Failed to get fee events", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -344,15 +364,15 @@ export class FeeConfigurationController { /** * Get fee analytics */ - @Get('analytics') + @Get("analytics") async getFeeAnalytics( - @Query('startDate') startDate: string, - @Query('endDate') endDate: string, + @Query("startDate") startDate: string, + @Query("endDate") endDate: string, ) { try { if (!startDate || !endDate) { throw new HttpException( - 'startDate and endDate query parameters are required', + "startDate and endDate query parameters are required", HttpStatus.BAD_REQUEST, ); } @@ -367,7 +387,7 @@ export class FeeConfigurationController { }; } catch (error) { throw new HttpException( - error.message || 'Failed to get fee analytics', + error.message || "Failed to get fee analytics", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -376,7 +396,7 @@ export class FeeConfigurationController { /** * Get admin settings */ - @Get('settings') + @Get("settings") async getAdminSettings() { try { const settings = await this.feeConfigurationService.getAdminSettings(); @@ -386,7 +406,7 @@ export class FeeConfigurationController { }; } catch (error) { throw new HttpException( - error.message || 'Failed to get admin settings', + error.message || "Failed to get admin settings", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -395,25 +415,26 @@ export class FeeConfigurationController { /** * Update admin settings */ - @Put('settings') + @Put("settings") async updateAdminSettings(@Body() settings: Partial) { try { // In production, get admin user ID from authentication - const adminUserId = 'admin-user'; // Placeholder - - const updatedSettings = await this.feeConfigurationService.updateAdminSettings( - settings, - adminUserId, - ); + const adminUserId = "admin-user"; // Placeholder + + const updatedSettings = + await this.feeConfigurationService.updateAdminSettings( + settings, + adminUserId, + ); return { success: true, data: updatedSettings, - message: 'Admin settings updated successfully', + message: "Admin settings updated successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to update admin settings', + error.message || "Failed to update admin settings", HttpStatus.BAD_REQUEST, ); } @@ -422,22 +443,26 @@ export class FeeConfigurationController { /** * Validate fee update before applying */ - @Post(':configId/validate') + @Post(":configId/validate") async validateUpdate( - @Param('configId') configId: string, + @Param("configId") configId: string, @Body() updateRequest: FeeUpdateRequest, ) { try { - const currentConfig = await this.feeConfigurationService.getCurrentConfiguration(); - const validation = await this.validateUpdate(currentConfig, updateRequest); - + const currentConfig = + await this.feeConfigurationService.getCurrentConfiguration(); + const validation = await this.validateUpdate( + currentConfig, + updateRequest, + ); + return { success: validation.isValid, data: validation, }; } catch (error) { throw new HttpException( - error.message || 'Failed to validate update', + error.message || "Failed to validate update", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -446,18 +471,25 @@ export class FeeConfigurationController { /** * Preview fee changes impact */ - @Post(':configId/preview') + @Post(":configId/preview") async previewChanges( - @Param('configId') configId: string, + @Param("configId") configId: string, @Body() updateRequest: FeeUpdateRequest, ) { try { - const currentConfig = await this.feeConfigurationService.getCurrentConfiguration(); - const validation = await this.validateUpdate(currentConfig, updateRequest); - + const currentConfig = + await this.feeConfigurationService.getCurrentConfiguration(); + const validation = await this.validateUpdate( + currentConfig, + updateRequest, + ); + // Calculate what the new configuration would look like - const previewConfig = this.applyPreviewChanges(currentConfig, updateRequest); - + const previewConfig = this.applyPreviewChanges( + currentConfig, + updateRequest, + ); + return { success: true, data: { @@ -470,7 +502,7 @@ export class FeeConfigurationController { }; } catch (error) { throw new HttpException( - error.message || 'Failed to preview changes', + error.message || "Failed to preview changes", HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -479,20 +511,20 @@ export class FeeConfigurationController { /** * Delete fee configuration */ - @Delete(':configId') - async deleteConfiguration(@Param('configId') configId: string) { + @Delete(":configId") + async deleteConfiguration(@Param("configId") configId: string) { try { // In production, get admin user ID from authentication - const adminUserId = 'admin-user'; // Placeholder - + const adminUserId = "admin-user"; // Placeholder + // This would need to be implemented in the service return { success: true, - message: 'Fee configuration deleted successfully', + message: "Fee configuration deleted successfully", }; } catch (error) { throw new HttpException( - error.message || 'Failed to delete configuration', + error.message || "Failed to delete configuration", HttpStatus.BAD_REQUEST, ); } @@ -501,15 +533,15 @@ export class FeeConfigurationController { /** * Restore fee configuration from history */ - @Post(':configId/restore/:version') + @Post(":configId/restore/:version") async restoreConfiguration( - @Param('configId') configId: string, - @Param('version') version: number, + @Param("configId") configId: string, + @Param("version") version: number, ) { try { // In production, get admin user ID from authentication - const adminUserId = 'admin-user'; // Placeholder - + const adminUserId = "admin-user"; // Placeholder + // This would need to be implemented in the service return { success: true, @@ -517,7 +549,7 @@ export class FeeConfigurationController { }; } catch (error) { throw new HttpException( - error.message || 'Failed to restore configuration', + error.message || "Failed to restore configuration", HttpStatus.BAD_REQUEST, ); } @@ -538,22 +570,30 @@ export class FeeConfigurationController { // Validate base price if (updateRequest.basePricePerRequest !== undefined) { if (updateRequest.basePricePerRequest < 0) { - errors.push('Base price per request cannot be negative'); + errors.push("Base price per request cannot be negative"); } if (updateRequest.basePricePerRequest > 1) { - warnings.push('Base price per request is very high (> 1 XLM)'); + warnings.push("Base price per request is very high (> 1 XLM)"); } } // Calculate impact const impact = { affectedUsers: 30000, // Example number - priceIncreasePercentage: updateRequest.basePricePerRequest && currentConfig.basePricePerRequest - ? ((updateRequest.basePricePerRequest - currentConfig.basePricePerRequest) / currentConfig.basePricePerRequest) * 100 - : 0, - priceDecreasePercentage: updateRequest.basePricePerRequest && currentConfig.basePricePerRequest - ? ((currentConfig.basePricePerRequest - updateRequest.basePricePerRequest) / currentConfig.basePricePerRequest) * 100 - : 0, + priceIncreasePercentage: + updateRequest.basePricePerRequest && currentConfig.basePricePerRequest + ? ((updateRequest.basePricePerRequest - + currentConfig.basePricePerRequest) / + currentConfig.basePricePerRequest) * + 100 + : 0, + priceDecreasePercentage: + updateRequest.basePricePerRequest && currentConfig.basePricePerRequest + ? ((currentConfig.basePricePerRequest - + updateRequest.basePricePerRequest) / + currentConfig.basePricePerRequest) * + 100 + : 0, }; return { @@ -627,7 +667,7 @@ export class FeeConfigurationController { if (oldConfig.basePricePerRequest !== newConfig.basePricePerRequest) { changes.push({ - field: 'basePricePerRequest', + field: "basePricePerRequest", oldValue: oldConfig.basePricePerRequest, newValue: newConfig.basePricePerRequest, }); @@ -635,12 +675,18 @@ export class FeeConfigurationController { // Check tier multipliers if (newConfig.tierMultipliers) { - Object.keys(oldConfig.tierMultipliers).forEach(tier => { - const oldValue = oldConfig.tierMultipliers[tier as keyof typeof oldConfig.tierMultipliers]; - const newValue = newConfig.tierMultipliers?.[tier as keyof typeof newConfig.tierMultipliers]; + Object.keys(oldConfig.tierMultipliers).forEach((tier) => { + const oldValue = + oldConfig.tierMultipliers[ + tier as keyof typeof oldConfig.tierMultipliers + ]; + const newValue = + newConfig.tierMultipliers?.[ + tier as keyof typeof newConfig.tierMultipliers + ]; if (oldValue !== newValue && newValue !== undefined) { changes.push({ - field: 'tierMultiplier', + field: "tierMultiplier", tier, oldValue, newValue, @@ -651,12 +697,18 @@ export class FeeConfigurationController { // Check discount percentages if (newConfig.discountPercentages) { - Object.keys(oldConfig.discountPercentages).forEach(tier => { - const oldValue = oldConfig.discountPercentages[tier as keyof typeof oldConfig.discountPercentages]; - const newValue = newConfig.discountPercentages?.[tier as keyof typeof newConfig.discountPercentages]; + Object.keys(oldConfig.discountPercentages).forEach((tier) => { + const oldValue = + oldConfig.discountPercentages[ + tier as keyof typeof oldConfig.discountPercentages + ]; + const newValue = + newConfig.discountPercentages?.[ + tier as keyof typeof newConfig.discountPercentages + ]; if (oldValue !== newValue && newValue !== undefined) { changes.push({ - field: 'discountPercentage', + field: "discountPercentage", tier, oldValue, newValue, diff --git a/apps/api-service/src/gas-estimation/dto/gas-estimate.dto.ts b/apps/api-service/src/gas-estimation/dto/gas-estimate.dto.ts index a74be7e..8f2f48c 100644 --- a/apps/api-service/src/gas-estimation/dto/gas-estimate.dto.ts +++ b/apps/api-service/src/gas-estimation/dto/gas-estimate.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNumber, IsOptional, IsDate } from 'class-validator'; +import { IsString, IsNumber, IsOptional, IsDate } from "class-validator"; export class GetGasEstimateDto { @IsString() @@ -13,7 +13,7 @@ export class GetGasEstimateDto { @IsOptional() @IsString() - priority?: 'low' | 'normal' | 'high' | 'critical'; + priority?: "low" | "normal" | "high" | "critical"; } export class GasEstimateResponseDto { diff --git a/apps/api-service/src/gas-estimation/entities/gas-price-history.entity.ts b/apps/api-service/src/gas-estimation/entities/gas-price-history.entity.ts index 86b09df..f11cb4d 100644 --- a/apps/api-service/src/gas-estimation/entities/gas-price-history.entity.ts +++ b/apps/api-service/src/gas-estimation/entities/gas-price-history.entity.ts @@ -4,48 +4,48 @@ import { Column, CreateDateColumn, Index, -} from 'typeorm'; +} from "typeorm"; -@Entity('gas_price_history') -@Index(['chainId', 'timestamp']) -@Index(['timestamp']) +@Entity("gas_price_history") +@Index(["chainId", "timestamp"]) +@Index(["timestamp"]) export class GasPriceHistory { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 50 }) + @Column({ type: "varchar", length: 50 }) chainId: string; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) chainName: string; @CreateDateColumn() timestamp: Date; - @Column({ type: 'numeric', precision: 18, scale: 6 }) + @Column({ type: "numeric", precision: 18, scale: 6 }) baseGasPrice: number; // stroops per instruction - @Column({ type: 'numeric', precision: 8, scale: 4 }) + @Column({ type: "numeric", precision: 8, scale: 4 }) surgeMultiplier: number; // 1.0 + congestion factor - @Column({ type: 'numeric', precision: 18, scale: 6 }) + @Column({ type: "numeric", precision: 18, scale: 6 }) effectiveGasPrice: number; // baseGasPrice * surgeMultiplier - @Column({ type: 'numeric', precision: 5, scale: 2 }) + @Column({ type: "numeric", precision: 5, scale: 2 }) networkLoad: number; // 0-100 - @Column({ type: 'numeric', precision: 18, scale: 0 }) + @Column({ type: "numeric", precision: 18, scale: 0 }) memoryPoolSize: number; // bytes - @Column({ type: 'int' }) + @Column({ type: "int" }) transactionCount: number; // transactions in block - @Column({ type: 'numeric', precision: 8, scale: 2 }) + @Column({ type: "numeric", precision: 8, scale: 2 }) blockTime: number; // ms - @Column({ type: 'numeric', precision: 5, scale: 2 }) + @Column({ type: "numeric", precision: 5, scale: 2 }) volatilityIndex: number; // 0-100 - @Column({ type: 'numeric', precision: 5, scale: 2 }) + @Column({ type: "numeric", precision: 5, scale: 2 }) priceConfidence: number; // 0-100 } diff --git a/apps/api-service/src/gas-estimation/gas-estimation.controller.ts b/apps/api-service/src/gas-estimation/gas-estimation.controller.ts index faa3e75..03d34fd 100644 --- a/apps/api-service/src/gas-estimation/gas-estimation.controller.ts +++ b/apps/api-service/src/gas-estimation/gas-estimation.controller.ts @@ -8,21 +8,21 @@ import { HttpStatus, BadRequestException, Logger, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { DynamicPricingService } from './services/dynamic-pricing.service'; -import { GasPriceHistoryService } from './services/gas-price-history.service'; -import { NetworkMonitorService } from './services/network-monitor.service'; -import { NetworkConfigService } from './config/network-config.service'; +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { DynamicPricingService } from "./services/dynamic-pricing.service"; +import { GasPriceHistoryService } from "./services/gas-price-history.service"; +import { NetworkMonitorService } from "./services/network-monitor.service"; +import { NetworkConfigService } from "./config/network-config.service"; import { GetGasEstimateDto, GasEstimateResponseDto, GasPriceHistoryDto, NetworkMetricsDto, -} from './dto/gas-estimate.dto'; +} from "./dto/gas-estimate.dto"; -@ApiTags('Gas Estimation') -@Controller('gas-estimation') +@ApiTags("Gas Estimation") +@Controller("gas-estimation") export class GasEstimationController { private readonly logger = new Logger(GasEstimationController.name); @@ -37,29 +37,31 @@ export class GasEstimationController { * Get dynamic gas estimate for a transaction * Replaces static gas estimation with real-time network-aware pricing */ - @Post('estimate') + @Post("estimate") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Get dynamic gas price estimate', + summary: "Get dynamic gas price estimate", description: - 'Returns estimated gas cost based on real-time network conditions, with multiple priority options', + "Returns estimated gas cost based on real-time network conditions, with multiple priority options", }) @ApiResponse({ status: 200, - description: 'Gas estimate with dynamic pricing', + description: "Gas estimate with dynamic pricing", type: GasEstimateResponseDto, }) async estimateGasPrice(@Body() dto: GetGasEstimateDto): Promise { try { if (!dto.chainId) { - throw new BadRequestException('chainId is required'); + throw new BadRequestException("chainId is required"); } if (!dto.estimatedGasUnits || dto.estimatedGasUnits <= 0) { - throw new BadRequestException('estimatedGasUnits must be greater than 0'); + throw new BadRequestException( + "estimatedGasUnits must be greater than 0", + ); } - const priority = dto.priority || 'normal'; + const priority = dto.priority || "normal"; const estimate = await this.dynamicPricingService.estimateGasPrice( dto.chainId, dto.estimatedGasUnits, @@ -71,7 +73,7 @@ export class GasEstimationController { confidence: estimate.alternativePrices ? 85 : 70, }; } catch (error) { - this.logger.error('Failed to estimate gas price', error); + this.logger.error("Failed to estimate gas price", error); throw error; } } @@ -79,17 +81,19 @@ export class GasEstimationController { /** * Get multiple price options for different priority levels */ - @Post('estimate/multi') + @Post("estimate/multi") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Get multiple gas price options', + summary: "Get multiple gas price options", description: - 'Returns gas price estimates for low, normal, high, and critical priority levels', + "Returns gas price estimates for low, normal, high, and critical priority levels", }) async getMultiplePriceOptions(@Body() dto: GetGasEstimateDto): Promise { try { if (!dto.chainId || !dto.estimatedGasUnits) { - throw new BadRequestException('chainId and estimatedGasUnits are required'); + throw new BadRequestException( + "chainId and estimatedGasUnits are required", + ); } return await this.dynamicPricingService.getMultiplePriceOptions( @@ -97,7 +101,7 @@ export class GasEstimationController { dto.estimatedGasUnits, ); } catch (error) { - this.logger.error('Failed to get multiple price options', error); + this.logger.error("Failed to get multiple price options", error); throw error; } } @@ -105,17 +109,19 @@ export class GasEstimationController { /** * Get optimal gas price suggestion based on historical patterns */ - @Post('suggest-optimal') + @Post("suggest-optimal") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'Get optimal gas price suggestion', + summary: "Get optimal gas price suggestion", description: - 'Recommends the best gas price based on historical trends and current conditions', + "Recommends the best gas price based on historical trends and current conditions", }) async suggestOptimalPrice(@Body() dto: GetGasEstimateDto): Promise { try { if (!dto.chainId || !dto.estimatedGasUnits) { - throw new BadRequestException('chainId and estimatedGasUnits are required'); + throw new BadRequestException( + "chainId and estimatedGasUnits are required", + ); } return await this.dynamicPricingService.suggestOptimalPrice( @@ -123,7 +129,7 @@ export class GasEstimationController { dto.estimatedGasUnits, ); } catch (error) { - this.logger.error('Failed to suggest optimal price', error); + this.logger.error("Failed to suggest optimal price", error); throw error; } } @@ -131,20 +137,22 @@ export class GasEstimationController { /** * Get current network metrics for a chain */ - @Get('network-metrics/:chainId') + @Get("network-metrics/:chainId") @ApiOperation({ - summary: 'Get current network metrics', + summary: "Get current network metrics", description: - 'Returns real-time network metrics including congestion level, transaction count, and volatility', + "Returns real-time network metrics including congestion level, transaction count, and volatility", }) - async getNetworkMetrics(@Param('chainId') chainId: string): Promise { + async getNetworkMetrics(@Param("chainId") chainId: string): Promise { try { if (!chainId) { - throw new BadRequestException('chainId is required'); + throw new BadRequestException("chainId is required"); } - const metrics = await this.networkMonitorService.getNetworkMetrics(chainId); - const snapshot = await this.networkMonitorService.getGasPriceSnapshot(chainId); + const metrics = + await this.networkMonitorService.getNetworkMetrics(chainId); + const snapshot = + await this.networkMonitorService.getGasPriceSnapshot(chainId); return { chainId, @@ -159,7 +167,7 @@ export class GasEstimationController { }, }; } catch (error) { - this.logger.error('Failed to get network metrics', error); + this.logger.error("Failed to get network metrics", error); throw error; } } @@ -167,18 +175,18 @@ export class GasEstimationController { /** * Get historical gas prices */ - @Get('history/:chainId') + @Get("history/:chainId") @ApiOperation({ - summary: 'Get historical gas prices', - description: 'Returns gas price history for analysis and trend detection', + summary: "Get historical gas prices", + description: "Returns gas price history for analysis and trend detection", }) async getGasPriceHistory( - @Param('chainId') chainId: string, + @Param("chainId") chainId: string, @Body() dto: GasPriceHistoryDto, ): Promise { try { if (!chainId) { - throw new BadRequestException('chainId is required'); + throw new BadRequestException("chainId is required"); } const hoursBack = dto.hoursBack || 24; @@ -203,7 +211,7 @@ export class GasEstimationController { trend, }; } catch (error) { - this.logger.error('Failed to get gas price history', error); + this.logger.error("Failed to get gas price history", error); throw error; } } @@ -211,24 +219,28 @@ export class GasEstimationController { /** * Get analysis of best time windows for low gas prices */ - @Get('best-time-windows/:chainId') + @Get("best-time-windows/:chainId") @ApiOperation({ - summary: 'Find best times for low gas prices', - description: 'Analyzes 7-day history to find optimal time windows with lowest gas prices', + summary: "Find best times for low gas prices", + description: + "Analyzes 7-day history to find optimal time windows with lowest gas prices", }) - async getBestTimeWindows(@Param('chainId') chainId: string): Promise { + async getBestTimeWindows(@Param("chainId") chainId: string): Promise { try { if (!chainId) { - throw new BadRequestException('chainId is required'); + throw new BadRequestException("chainId is required"); } const bestWindows = - await this.gasPriceHistoryService.getBestTimeWindowsForLowPrices(chainId, 5); + await this.gasPriceHistoryService.getBestTimeWindowsForLowPrices( + chainId, + 5, + ); return { chainId, - analysisWindow: '7 days', - timezone: 'UTC', + analysisWindow: "7 days", + timezone: "UTC", bestWindows: bestWindows.map((w) => ({ hour: `${w.hour}:00 UTC`, averagePrice: w.averagePrice.toFixed(2), @@ -237,7 +249,7 @@ export class GasEstimationController { })), }; } catch (error) { - this.logger.error('Failed to get best time windows', error); + this.logger.error("Failed to get best time windows", error); throw error; } } @@ -245,15 +257,16 @@ export class GasEstimationController { /** * Get price trend analysis */ - @Get('trend/:chainId') + @Get("trend/:chainId") @ApiOperation({ - summary: 'Get gas price trend', - description: 'Analyzes recent gas price trends to predict future price movements', + summary: "Get gas price trend", + description: + "Analyzes recent gas price trends to predict future price movements", }) - async getPriceTrend(@Param('chainId') chainId: string): Promise { + async getPriceTrend(@Param("chainId") chainId: string): Promise { try { if (!chainId) { - throw new BadRequestException('chainId is required'); + throw new BadRequestException("chainId is required"); } const trend = await this.gasPriceHistoryService.getPriceTrend(chainId); @@ -273,10 +286,13 @@ export class GasEstimationController { max6h: stats.max, volatility: stats.stdDev, }, - recommendation: this.getTrendRecommendation(trend.trend, trend.percentChange), + recommendation: this.getTrendRecommendation( + trend.trend, + trend.percentChange, + ), }; } catch (error) { - this.logger.error('Failed to get price trend', error); + this.logger.error("Failed to get price trend", error); throw error; } } @@ -284,14 +300,14 @@ export class GasEstimationController { /** * Health check for gas estimation service */ - @Get('health') + @Get("health") @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Health check' }) + @ApiOperation({ summary: "Health check" }) async health(): Promise { return { - status: 'healthy', + status: "healthy", timestamp: new Date().toISOString(), - version: '1.0.0', + version: "1.0.0", supportedChains: this.networkConfigService.getSupportedChainIds(), }; } @@ -299,22 +315,19 @@ export class GasEstimationController { /** * Helper: Get trend-based recommendation */ - private getTrendRecommendation( - trend: string, - percentChange: number, - ): string { - if (trend === 'increasing') { + private getTrendRecommendation(trend: string, percentChange: number): string { + if (trend === "increasing") { if (percentChange > 10) { - return '⚠️ Prices rising sharply. Consider executing urgent transactions now if needed.'; + return "⚠️ Prices rising sharply. Consider executing urgent transactions now if needed."; } - return '📈 Prices trending up. Good time to execute if not urgent.'; - } else if (trend === 'decreasing') { + return "📈 Prices trending up. Good time to execute if not urgent."; + } else if (trend === "decreasing") { if (percentChange < -10) { - return '✅ Prices falling significantly. Optimal time for non-urgent transactions.'; + return "✅ Prices falling significantly. Optimal time for non-urgent transactions."; } - return '📉 Prices trending down. Wait for cheaper rates if possible.'; + return "📉 Prices trending down. Wait for cheaper rates if possible."; } else { - return '➡️ Prices stable. Safe to execute at any time.'; + return "➡️ Prices stable. Safe to execute at any time."; } } } diff --git a/apps/api-service/src/gas-estimation/gas-estimation.module.ts b/apps/api-service/src/gas-estimation/gas-estimation.module.ts index c1b8622..6aefafd 100644 --- a/apps/api-service/src/gas-estimation/gas-estimation.module.ts +++ b/apps/api-service/src/gas-estimation/gas-estimation.module.ts @@ -1,12 +1,12 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { GasEstimationController } from './gas-estimation.controller'; -import { NetworkConfigService } from './config/network-config.service'; -import { NetworkMonitorService } from './services/network-monitor.service'; -import { DynamicPricingService } from './services/dynamic-pricing.service'; -import { GasPriceHistoryService } from './services/gas-price-history.service'; -import { GasPriceHistory } from './entities/gas-price-history.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { GasEstimationController } from "./gas-estimation.controller"; +import { NetworkConfigService } from "./config/network-config.service"; +import { NetworkMonitorService } from "./services/network-monitor.service"; +import { DynamicPricingService } from "./services/dynamic-pricing.service"; +import { GasPriceHistoryService } from "./services/gas-price-history.service"; +import { GasPriceHistory } from "./entities/gas-price-history.entity"; @Module({ imports: [ diff --git a/apps/api-service/src/gas-estimation/index.ts b/apps/api-service/src/gas-estimation/index.ts index 4c09dae..a9de8a6 100644 --- a/apps/api-service/src/gas-estimation/index.ts +++ b/apps/api-service/src/gas-estimation/index.ts @@ -1,8 +1,8 @@ -export { GasEstimationModule } from './gas-estimation.module'; -export { GasEstimationController } from './gas-estimation.controller'; -export * from './config/network-config.service'; -export * from './services/network-monitor.service'; -export * from './services/dynamic-pricing.service'; -export * from './services/gas-price-history.service'; -export * from './interfaces/gas-price.interface'; -export * from './dto/gas-estimate.dto'; +export { GasEstimationModule } from "./gas-estimation.module"; +export { GasEstimationController } from "./gas-estimation.controller"; +export * from "./config/network-config.service"; +export * from "./services/network-monitor.service"; +export * from "./services/dynamic-pricing.service"; +export * from "./services/gas-price-history.service"; +export * from "./interfaces/gas-price.interface"; +export * from "./dto/gas-estimate.dto"; diff --git a/apps/api-service/src/gas-estimation/interfaces/gas-price.interface.ts b/apps/api-service/src/gas-estimation/interfaces/gas-price.interface.ts index a9ab679..0b0927d 100644 --- a/apps/api-service/src/gas-estimation/interfaces/gas-price.interface.ts +++ b/apps/api-service/src/gas-estimation/interfaces/gas-price.interface.ts @@ -30,7 +30,7 @@ export interface DynamicGasEstimate { totalEstimatedCostXLM: number; priceValidityDurationMs: number; // how long this price is valid expiresAt: Date; - recommendedPriority: 'low' | 'normal' | 'high' | 'critical'; + recommendedPriority: "low" | "normal" | "high" | "critical"; alternativePrices?: { low: number; medium: number; diff --git a/apps/api-service/src/gas-estimation/interfaces/tiered-pricing.interface.ts b/apps/api-service/src/gas-estimation/interfaces/tiered-pricing.interface.ts index 390e25a..5cd927b 100644 --- a/apps/api-service/src/gas-estimation/interfaces/tiered-pricing.interface.ts +++ b/apps/api-service/src/gas-estimation/interfaces/tiered-pricing.interface.ts @@ -4,10 +4,10 @@ */ export enum UsageTier { - STARTER = 'starter', - DEVELOPER = 'developer', - PROFESSIONAL = 'professional', - ENTERPRISE = 'enterprise', + STARTER = "starter", + DEVELOPER = "developer", + PROFESSIONAL = "professional", + ENTERPRISE = "enterprise", } export interface TierConfig { @@ -92,7 +92,12 @@ export interface TierTransition { fromTier: UsageTier; toTier: UsageTier; effectiveDate: Date; - reason: 'usage_upgrade' | 'usage_downgrade' | 'manual_upgrade' | 'manual_downgrade' | 'admin_change'; + reason: + | "usage_upgrade" + | "usage_downgrade" + | "manual_upgrade" + | "manual_downgrade" + | "admin_change"; prorationRequired: boolean; notificationRequired: boolean; } @@ -102,6 +107,6 @@ export interface TierValidationResult { currentTier: UsageTier; canProceed: boolean; message: string; - suggestedAction?: 'upgrade' | 'downgrade' | 'continue' | 'contact_support'; + suggestedAction?: "upgrade" | "downgrade" | "continue" | "contact_support"; nextAvailableTier?: UsageTier; } diff --git a/apps/api-service/src/gas-estimation/services/fee-configuration.service.ts b/apps/api-service/src/gas-estimation/services/fee-configuration.service.ts index 869308a..3939ba9 100644 --- a/apps/api-service/src/gas-estimation/services/fee-configuration.service.ts +++ b/apps/api-service/src/gas-estimation/services/fee-configuration.service.ts @@ -220,9 +220,8 @@ export class FeeConfigurationService { ); } - const isRequesterSigner = this.adminSettings.multisigSigners.includes( - adminUserId, - ); + const isRequesterSigner = + this.adminSettings.multisigSigners.includes(adminUserId); const effectiveDate = this.getEffectiveDateWithDelay(updateRequest); @@ -262,16 +261,16 @@ export class FeeConfigurationService { return { ...approvalRequest }; } - async getApprovalRequests( - configId?: string, - ): Promise { + async getApprovalRequests(configId?: string): Promise { const requests = Array.from(this.pendingApprovals.values()); return configId ? requests.filter((request) => request.configurationId === configId) : requests; } - async getApprovalRequest(approvalRequestId: string): Promise { + async getApprovalRequest( + approvalRequestId: string, + ): Promise { const approvalRequest = this.pendingApprovals.get(approvalRequestId); if (!approvalRequest) { throw new NotFoundException( @@ -399,9 +398,7 @@ export class FeeConfigurationService { ); } - async getScheduledUpdates( - configId?: string, - ): Promise { + async getScheduledUpdates(configId?: string): Promise { const updates = Array.from(this.scheduledUpdates.values()); return configId ? updates.filter((update) => update.configurationId === configId) @@ -474,9 +471,7 @@ export class FeeConfigurationService { return { ...scheduledUpdate }; } - private getEffectiveDateWithDelay( - updateRequest: FeeUpdateRequest, - ): Date { + private getEffectiveDateWithDelay(updateRequest: FeeUpdateRequest): Date { const now = new Date(); const delayMs = this.adminSettings.timelockDelayMinutes * 60 * 1000; const minimumEffectiveDate = new Date(now.getTime() + delayMs); diff --git a/apps/api-service/src/gas-estimation/services/gas-price-history.service.ts b/apps/api-service/src/gas-estimation/services/gas-price-history.service.ts index 1f9975b..f739a8e 100644 --- a/apps/api-service/src/gas-estimation/services/gas-price-history.service.ts +++ b/apps/api-service/src/gas-estimation/services/gas-price-history.service.ts @@ -1,9 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { GasPriceHistory as GasPriceHistoryEntity } from '../entities/gas-price-history.entity'; -import { GasPriceSnapshot, GasPriceHistory } from '../interfaces/gas-price.interface'; -import { NetworkMonitorService } from './network-monitor.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { GasPriceHistory as GasPriceHistoryEntity } from "../entities/gas-price-history.entity"; +import { + GasPriceSnapshot, + GasPriceHistory, +} from "../interfaces/gas-price.interface"; +import { NetworkMonitorService } from "./network-monitor.service"; /** * GasPriceHistoryService @@ -22,7 +25,9 @@ export class GasPriceHistoryService { /** * Record current gas price snapshot to history */ - async recordPriceSnapshot(snapshot: GasPriceSnapshot): Promise { + async recordPriceSnapshot( + snapshot: GasPriceSnapshot, + ): Promise { const history = this.gasPriceHistoryRepository.create({ chainId: snapshot.chainId, chainName: snapshot.chainName, @@ -55,7 +60,7 @@ export class GasPriceHistoryService { timestamp: { gte: startTime }, }, order: { - timestamp: 'ASC', + timestamp: "ASC", }, }); @@ -107,14 +112,14 @@ export class GasPriceHistoryService { chainId: string, windowSizeHours: number = 6, ): Promise<{ - trend: 'increasing' | 'decreasing' | 'stable'; + trend: "increasing" | "decreasing" | "stable"; percentChange: number; confidence: number; }> { const history = await this.getPriceHistory(chainId, windowSizeHours * 2); if (history.length < 2) { - return { trend: 'stable', percentChange: 0, confidence: 0 }; + return { trend: "stable", percentChange: 0, confidence: 0 }; } const midpoint = Math.floor(history.length / 2); @@ -122,20 +127,24 @@ export class GasPriceHistoryService { const secondHalf = history.slice(midpoint); const firstAvg = - firstHalf.reduce((sum, h) => sum + h.effectivePrice, 0) / firstHalf.length; + firstHalf.reduce((sum, h) => sum + h.effectivePrice, 0) / + firstHalf.length; const secondAvg = - secondHalf.reduce((sum, h) => sum + h.effectivePrice, 0) / secondHalf.length; + secondHalf.reduce((sum, h) => sum + h.effectivePrice, 0) / + secondHalf.length; const percentChange = ((secondAvg - firstAvg) / firstAvg) * 100; const trend = percentChange > 2 - ? 'increasing' + ? "increasing" : percentChange < -2 - ? 'decreasing' - : 'stable'; + ? "decreasing" + : "stable"; // Confidence based on consistency of trend - const variance = this.calculateVariance(secondHalf.map((h) => h.effectivePrice)); + const variance = this.calculateVariance( + secondHalf.map((h) => h.effectivePrice), + ); const confidence = Math.max(0, 100 - variance * 2); return { trend, percentChange, confidence }; @@ -201,9 +210,11 @@ export class GasPriceHistoryService { await this.gasPriceHistoryRepository.delete({ timestamp: { lt: cutoffDate }, }); - this.logger.log(`Cleaned up gas price history older than ${olderThanDays} days`); + this.logger.log( + `Cleaned up gas price history older than ${olderThanDays} days`, + ); } catch (error) { - this.logger.error('Failed to cleanup old records', error); + this.logger.error("Failed to cleanup old records", error); } } } diff --git a/apps/api-service/src/gas-estimation/services/network-monitor.service.ts b/apps/api-service/src/gas-estimation/services/network-monitor.service.ts index 03130e6..41386e9 100644 --- a/apps/api-service/src/gas-estimation/services/network-monitor.service.ts +++ b/apps/api-service/src/gas-estimation/services/network-monitor.service.ts @@ -1,7 +1,10 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { NetworkMetrics, GasPriceSnapshot } from '../interfaces/gas-price.interface'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { NetworkConfigService } from '../config/network-config.service'; +import { Injectable, Logger } from "@nestjs/common"; +import { + NetworkMetrics, + GasPriceSnapshot, +} from "../interfaces/gas-price.interface"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { NetworkConfigService } from "../config/network-config.service"; /** * NetworkMonitorService @@ -65,23 +68,28 @@ export class NetworkMonitorService { this.priceSnapshotCache.set(chainId, snapshot); } - this.logger.debug('Network metrics updated successfully'); + this.logger.debug("Network metrics updated successfully"); } catch (error) { - this.logger.error('Failed to update network metrics', error); + this.logger.error("Failed to update network metrics", error); } } /** * Fetch metrics from Soroban RPC endpoint */ - private async fetchNetworkMetricsFromRpc(chainId: string): Promise { + private async fetchNetworkMetricsFromRpc( + chainId: string, + ): Promise { // This would connect to actual Soroban RPC in production // For now, return mock data with some randomization to simulate network conditions const network = this.networkConfigService.getNetworkConfig(chainId); const baseLoad = network.baselineLoad; const randomFluctuation = Math.random() * 30 - 15; // -15 to +15 - const congestionLevel = Math.max(0, Math.min(100, baseLoad + randomFluctuation)); + const congestionLevel = Math.max( + 0, + Math.min(100, baseLoad + randomFluctuation), + ); return { congestionLevel, @@ -100,12 +108,16 @@ export class NetworkMonitorService { /** * Create a gas price snapshot based on current metrics */ - private async createGasPriceSnapshot(chainId: string): Promise { + private async createGasPriceSnapshot( + chainId: string, + ): Promise { const network = this.networkConfigService.getNetworkConfig(chainId); const metrics = await this.getNetworkMetrics(chainId); // Calculate surge multiplier based on congestion - const surgeMultiplier = this.calculateSurgeMultiplier(metrics.congestionLevel); + const surgeMultiplier = this.calculateSurgeMultiplier( + metrics.congestionLevel, + ); // Base price (stroops per instruction) - Soroban default const basePrice = network.baseFeePerInstruction; @@ -141,10 +153,10 @@ export class NetworkMonitorService { return 1.0; } else if (congestionLevel < 60) { // Medium congestion: linear increase - return 1.0 + (congestionLevel - 30) / 30 * 0.5; // 1.0 to 1.5 + return 1.0 + ((congestionLevel - 30) / 30) * 0.5; // 1.0 to 1.5 } else if (congestionLevel < 85) { // High congestion: accelerated increase - return 1.5 + (congestionLevel - 60) / 25 * 1.0; // 1.5 to 2.5 + return 1.5 + ((congestionLevel - 60) / 25) * 1.0; // 1.5 to 2.5 } else { // Critical congestion: exponential scaling const excessCongestion = congestionLevel - 85; diff --git a/apps/api-service/src/gas-subsidy/controllers/gas-subsidy.controller.ts b/apps/api-service/src/gas-subsidy/controllers/gas-subsidy.controller.ts index 1af52a5..2f96e99 100644 --- a/apps/api-service/src/gas-subsidy/controllers/gas-subsidy.controller.ts +++ b/apps/api-service/src/gas-subsidy/controllers/gas-subsidy.controller.ts @@ -1,9 +1,28 @@ -import { Controller, Get, Post, Put, Param, Body, Query, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiResponse } from '@nestjs/swagger'; -import { GasSubsidyService, SubsidyUsageRecord, CreateCapDto } from '../services/gas-subsidy.service'; -import { SubsidyCapType, SubsidyStatus } from '../entities/gas-subsidy.entity'; -import { AdminOnly, OperatorAndAbove, ViewerAndAbove } from '../../rbac/decorators'; -import { RolesGuard } from '../../rbac/guards'; +import { + Controller, + Get, + Post, + Put, + Param, + Body, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from "@nestjs/common"; +import { ApiTags, ApiResponse } from "@nestjs/swagger"; +import { + GasSubsidyService, + SubsidyUsageRecord, + CreateCapDto, +} from "../services/gas-subsidy.service"; +import { SubsidyCapType, SubsidyStatus } from "../entities/gas-subsidy.entity"; +import { + AdminOnly, + OperatorAndAbove, + ViewerAndAbove, +} from "../../rbac/decorators"; +import { RolesGuard } from "../../rbac/guards"; interface CheckSubsidyDto { amount: number; @@ -13,116 +32,143 @@ interface AcknowledgeAlertDto { acknowledgedBy: string; } -@ApiTags('Gas Subsidy Management') -@Controller('api/subsidy') +@ApiTags("Gas Subsidy Management") +@Controller("api/subsidy") @UseGuards(RolesGuard) export class GasSubsidyController { constructor(private readonly gasSubsidyService: GasSubsidyService) {} - @Get('health') + @Get("health") // eslint-disable-next-line @typescript-eslint/no-unused-vars health() { - return { status: 'ok', service: 'gas-subsidy' }; + return { status: "ok", service: "gas-subsidy" }; } - @Post('caps') + @Post("caps") @AdminOnly() @HttpCode(HttpStatus.CREATED) - @ApiResponse({ status: 403, description: 'Forbidden - requires admin role' }) + @ApiResponse({ status: 403, description: "Forbidden - requires admin role" }) // eslint-disable-next-line @typescript-eslint/no-unused-vars async createCap(@Body() dto: CreateCapDto) { const cap = await this.gasSubsidyService.createCap(dto); return { success: true, cap }; } - @Get('caps/:walletAddress') + @Get("caps/:walletAddress") @OperatorAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) // eslint-disable-next-line @typescript-eslint/no-unused-vars async getCap( - @Param('walletAddress') walletAddress: string, - @Query('capType') capType?: SubsidyCapType, + @Param("walletAddress") walletAddress: string, + @Query("capType") capType?: SubsidyCapType, ) { return this.gasSubsidyService.getCap(walletAddress, capType); } - @Get('caps') + @Get("caps") @OperatorAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getAllCaps(@Query('status') status?: SubsidyStatus) { + async getAllCaps(@Query("status") status?: SubsidyStatus) { return this.gasSubsidyService.getAllCaps(status); } - @Post('check/:walletAddress') + @Post("check/:walletAddress") @OperatorAndAbove() @HttpCode(HttpStatus.OK) - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) // eslint-disable-next-line @typescript-eslint/no-unused-vars async checkSubsidy( - @Param('walletAddress') walletAddress: string, + @Param("walletAddress") walletAddress: string, @Body() dto: CheckSubsidyDto, ) { return this.gasSubsidyService.checkSubsidy(walletAddress, dto.amount); } - @Post('usage') + @Post("usage") @OperatorAndAbove() @HttpCode(HttpStatus.CREATED) - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) async recordUsage(@Body() record: SubsidyUsageRecord) { const log = await this.gasSubsidyService.recordUsage(record); return { success: true, log }; } - @Get('usage/:walletAddress') + @Get("usage/:walletAddress") @OperatorAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) async getUsageLogs( - @Param('walletAddress') walletAddress: string, - @Query('limit') limit?: number, + @Param("walletAddress") walletAddress: string, + @Query("limit") limit?: number, ) { return this.gasSubsidyService.getUsageLogs(walletAddress, limit || 100); } - @Get('alerts') + @Get("alerts") @OperatorAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getAlerts(@Query('walletAddress') walletAddress?: string) { + async getAlerts(@Query("walletAddress") walletAddress?: string) { return this.gasSubsidyService.getActiveAlerts(walletAddress); } - @Put('alerts/:alertId/acknowledge') + @Put("alerts/:alertId/acknowledge") @OperatorAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) async acknowledgeAlert( - @Param('alertId') alertId: string, + @Param("alertId") alertId: string, @Body() dto: AcknowledgeAlertDto, ) { - const alert = await this.gasSubsidyService.acknowledgeAlert(alertId, dto.acknowledgedBy); + const alert = await this.gasSubsidyService.acknowledgeAlert( + alertId, + dto.acknowledgedBy, + ); return { success: true, alert }; } - @Get('flags') + @Get("flags") @AdminOnly() - @ApiResponse({ status: 403, description: 'Forbidden - requires admin role' }) + @ApiResponse({ status: 403, description: "Forbidden - requires admin role" }) // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getSuspiciousFlags(@Query('walletAddress') walletAddress?: string) { + async getSuspiciousFlags(@Query("walletAddress") walletAddress?: string) { return this.gasSubsidyService.getSuspiciousFlags(walletAddress); } - @Put('flags/:flagId/clear') + @Put("flags/:flagId/clear") @AdminOnly() - @ApiResponse({ status: 403, description: 'Forbidden - requires admin role' }) - async clearFlag(@Param('flagId') flagId: string) { + @ApiResponse({ status: 403, description: "Forbidden - requires admin role" }) + async clearFlag(@Param("flagId") flagId: string) { const flag = await this.gasSubsidyService.clearSuspiciousFlag(flagId); return { success: true, flag }; } - @Get('realtime') + @Get("realtime") @ViewerAndAbove() - @ApiResponse({ status: 403, description: 'Forbidden - requires authentication' }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires authentication", + }) async getRealtimeSummary() { return this.gasSubsidyService.getRealtimeSummary(); } diff --git a/apps/api-service/src/gas-subsidy/entities/gas-subsidy.entity.ts b/apps/api-service/src/gas-subsidy/entities/gas-subsidy.entity.ts index 119e90a..2a39e92 100644 --- a/apps/api-service/src/gas-subsidy/entities/gas-subsidy.entity.ts +++ b/apps/api-service/src/gas-subsidy/entities/gas-subsidy.entity.ts @@ -1,188 +1,194 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; export enum SubsidyCapType { - DAILY = 'daily', - WEEKLY = 'weekly', - MONTHLY = 'monthly', - LIFETIME = 'lifetime', + DAILY = "daily", + WEEKLY = "weekly", + MONTHLY = "monthly", + LIFETIME = "lifetime", } export enum SubsidyStatus { - ACTIVE = 'active', - SUSPENDED = 'suspended', - EXCEEDED = 'exceeded', - FLAGGED = 'flagged', + ACTIVE = "active", + SUSPENDED = "suspended", + EXCEEDED = "exceeded", + FLAGGED = "flagged", } export enum AlertLevel { - NONE = 'none', - WARNING = 'warning', - CRITICAL = 'critical', - BLOCKED = 'blocked', + NONE = "none", + WARNING = "warning", + CRITICAL = "critical", + BLOCKED = "blocked", } -@Entity('gas_subsidy_caps') +@Entity("gas_subsidy_caps") export class GasSubsidyCap { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_gsc_wallet_address') + @Column({ type: "varchar", length: 100 }) + @Index("idx_gsc_wallet_address") walletAddress: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) userId: string; - @Column({ type: 'varchar', length: 20 }) + @Column({ type: "varchar", length: 20 }) capType: SubsidyCapType; - @Column({ type: 'decimal', precision: 30, scale: 0 }) + @Column({ type: "decimal", precision: 30, scale: 0 }) maxSubsidyAmount: number; // in minimal units (e.g., Wei for ETH) - @Column({ type: 'decimal', precision: 30, scale: 0, default: 0 }) + @Column({ type: "decimal", precision: 30, scale: 0, default: 0 }) currentUsage: number; - @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) usagePercentage: number; - @Column({ type: 'varchar', length: 20 }) + @Column({ type: "varchar", length: 20 }) status: SubsidyStatus; - @Column({ type: 'decimal', precision: 10, scale: 2, default: 80 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 80 }) warningThreshold: number; // Percentage at which to warn - @Column({ type: 'decimal', precision: 10, scale: 2, default: 100 }) + @Column({ type: "decimal", precision: 10, scale: 2, default: 100 }) hardCap: number; // Hard cap percentage - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) periodStart: Date; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) periodEnd: Date; @CreateDateColumn() createdAt: Date; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) lastUsageAt: Date; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) relayerId: string; - @Column({ type: 'boolean', default: false }) + @Column({ type: "boolean", default: false }) isRelayerWallet: boolean; } -@Entity('gas_subsidy_usage_logs') +@Entity("gas_subsidy_usage_logs") export class GasSubsidyUsageLog { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_gsul_wallet_address') + @Column({ type: "varchar", length: 100 }) + @Index("idx_gsul_wallet_address") walletAddress: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) userId: string; - @Column({ type: 'decimal', precision: 30, scale: 0 }) + @Column({ type: "decimal", precision: 30, scale: 0 }) amount: number; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) transactionHash: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) chainId: string; - @Column({ type: 'varchar', length: 255, nullable: true }) + @Column({ type: "varchar", length: 255, nullable: true }) description: string; - @Column({ type: 'timestamp' }) - @Index('idx_gsul_timestamp') + @Column({ type: "timestamp" }) + @Index("idx_gsul_timestamp") timestamp: Date; @CreateDateColumn() createdAt: Date; } -@Entity('gas_subsidy_alerts') +@Entity("gas_subsidy_alerts") export class GasSubsidyAlert { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) + @Column({ type: "varchar", length: 100 }) walletAddress: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) userId: string; - @Column({ type: 'varchar', length: 20 }) + @Column({ type: "varchar", length: 20 }) alertLevel: AlertLevel; - @Column({ type: 'text' }) + @Column({ type: "text" }) message: string; - @Column({ type: 'decimal', precision: 30, scale: 0 }) + @Column({ type: "decimal", precision: 30, scale: 0 }) currentUsage: number; - @Column({ type: 'decimal', precision: 30, scale: 0 }) + @Column({ type: "decimal", precision: 30, scale: 0 }) maxSubsidy: number; - @Column({ type: 'decimal', precision: 10, scale: 2 }) + @Column({ type: "decimal", precision: 10, scale: 2 }) usagePercentage: number; - @Column({ type: 'boolean', default: false }) + @Column({ type: "boolean", default: false }) acknowledged: boolean; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) acknowledgedAt: Date; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) acknowledgedBy: string; - @Column({ type: 'timestamp' }) - @Index('idx_gsa_timestamp') + @Column({ type: "timestamp" }) + @Index("idx_gsa_timestamp") timestamp: Date; @CreateDateColumn() createdAt: Date; } -@Entity('suspicious_usage_flags') +@Entity("suspicious_usage_flags") export class SuspiciousUsageFlag { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_suf_wallet_address') + @Column({ type: "varchar", length: 100 }) + @Index("idx_suf_wallet_address") walletAddress: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) userId: string; - @Column({ type: 'varchar', length: 100 }) + @Column({ type: "varchar", length: 100 }) flagType: string; // e.g., 'rapid_transactions', 'unusual_pattern', 'threshold_exceeded' - @Column({ type: 'text' }) + @Column({ type: "text" }) description: string; - @Column({ type: 'integer', default: 1 }) + @Column({ type: "integer", default: 1 }) severity: number; // 1-10 scale - @Column({ type: 'boolean', default: true }) + @Column({ type: "boolean", default: true }) active: boolean; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata: Record; - @Column({ type: 'timestamp' }) - @Index('idx_suf_timestamp') + @Column({ type: "timestamp" }) + @Index("idx_suf_timestamp") firstDetectedAt: Date; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) lastDetectedAt: Date; - @Column({ type: 'integer', default: 1 }) + @Column({ type: "integer", default: 1 }) occurrenceCount: number; @CreateDateColumn() diff --git a/apps/api-service/src/gas-subsidy/gas-subsidy.module.ts b/apps/api-service/src/gas-subsidy/gas-subsidy.module.ts index ea90ab4..38299f5 100644 --- a/apps/api-service/src/gas-subsidy/gas-subsidy.module.ts +++ b/apps/api-service/src/gas-subsidy/gas-subsidy.module.ts @@ -1,13 +1,13 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { GasSubsidyCap, GasSubsidyUsageLog, GasSubsidyAlert, SuspiciousUsageFlag, -} from './entities/gas-subsidy.entity'; -import { GasSubsidyService } from './services/gas-subsidy.service'; -import { GasSubsidyController } from './controllers/gas-subsidy.controller'; +} from "./entities/gas-subsidy.entity"; +import { GasSubsidyService } from "./services/gas-subsidy.service"; +import { GasSubsidyController } from "./controllers/gas-subsidy.controller"; @Module({ imports: [ diff --git a/apps/api-service/src/gas-subsidy/index.ts b/apps/api-service/src/gas-subsidy/index.ts index 9af1208..9bf2109 100644 --- a/apps/api-service/src/gas-subsidy/index.ts +++ b/apps/api-service/src/gas-subsidy/index.ts @@ -1,3 +1,3 @@ -export * from './entities/gas-subsidy.entity'; -export * from './services/gas-subsidy.service'; -export * from './gas-subsidy.module'; +export * from "./entities/gas-subsidy.entity"; +export * from "./services/gas-subsidy.service"; +export * from "./gas-subsidy.module"; diff --git a/apps/api-service/src/gas-subsidy/services/gas-subsidy.service.ts b/apps/api-service/src/gas-subsidy/services/gas-subsidy.service.ts index 8a94c77..780ee3b 100644 --- a/apps/api-service/src/gas-subsidy/services/gas-subsidy.service.ts +++ b/apps/api-service/src/gas-subsidy/services/gas-subsidy.service.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; import { GasSubsidyCap, GasSubsidyUsageLog, @@ -9,7 +9,7 @@ import { SubsidyCapType, SubsidyStatus, AlertLevel, -} from '../entities/gas-subsidy.entity'; +} from "../entities/gas-subsidy.entity"; export interface SubsidyUsageRecord { walletAddress: string; @@ -84,7 +84,10 @@ export class GasSubsidyService { /** * Get subsidy cap for a wallet */ - async getCap(walletAddress: string, capType?: SubsidyCapType): Promise { + async getCap( + walletAddress: string, + capType?: SubsidyCapType, + ): Promise { const where: any = { walletAddress }; if (capType) { where.capType = capType; @@ -92,14 +95,17 @@ export class GasSubsidyService { return this.capRepository.findOne({ where, - order: { createdAt: 'DESC' }, + order: { createdAt: "DESC" }, }); } /** * Check if usage is allowed and get current subsidy status */ - async checkSubsidy(walletAddress: string, amount: number): Promise { + async checkSubsidy( + walletAddress: string, + amount: number, + ): Promise { const cap = await this.getCap(walletAddress); if (!cap) { @@ -111,7 +117,7 @@ export class GasSubsidyService { maxSubsidy: 0, usagePercentage: 0, alertLevel: AlertLevel.NONE, - message: 'No subsidy cap defined', + message: "No subsidy cap defined", }; } @@ -130,15 +136,15 @@ export class GasSubsidyService { // Determine if allowed let allowed = true; let alertLevel = AlertLevel.NONE; - let message = 'Subsidy available'; + let message = "Subsidy available"; if (usagePercentage >= cap.hardCap) { allowed = false; alertLevel = AlertLevel.BLOCKED; - message = 'Subsidy cap exceeded'; + message = "Subsidy cap exceeded"; } else if (usagePercentage >= cap.warningThreshold) { alertLevel = AlertLevel.WARNING; - message = 'Approaching subsidy cap'; + message = "Approaching subsidy cap"; } // Check for suspicious patterns @@ -168,10 +174,15 @@ export class GasSubsidyService { */ async recordUsage(record: SubsidyUsageRecord): Promise { // First check subsidy availability - const checkResult = await this.checkSubsidy(record.walletAddress, record.amount); + const checkResult = await this.checkSubsidy( + record.walletAddress, + record.amount, + ); if (!checkResult.allowed) { - throw new Error(`Subsidy not available for wallet ${record.walletAddress}`); + throw new Error( + `Subsidy not available for wallet ${record.walletAddress}`, + ); } // Log the usage @@ -202,12 +213,16 @@ export class GasSubsidyService { /** * Update cap usage after recording */ - private async updateCapUsage(walletAddress: string, amount: number): Promise { + private async updateCapUsage( + walletAddress: string, + amount: number, + ): Promise { const cap = await this.getCap(walletAddress); if (!cap) return; cap.currentUsage = Number(cap.currentUsage) + amount; - cap.usagePercentage = (Number(cap.currentUsage) / Number(cap.maxSubsidyAmount)) * 100; + cap.usagePercentage = + (Number(cap.currentUsage) / Number(cap.maxSubsidyAmount)) * 100; cap.lastUsageAt = new Date(); // Update status based on usage @@ -225,7 +240,7 @@ export class GasSubsidyService { */ private async resetCapPeriod(cap: GasSubsidyCap): Promise { const { periodStart, periodEnd } = this.calculatePeriod(cap.capType); - + cap.periodStart = periodStart; cap.periodEnd = periodEnd; cap.currentUsage = 0; @@ -238,7 +253,10 @@ export class GasSubsidyService { /** * Calculate period dates based on cap type */ - private calculatePeriod(capType: SubsidyCapType): { periodStart: Date; periodEnd: Date } { + private calculatePeriod(capType: SubsidyCapType): { + periodStart: Date; + periodEnd: Date; + } { const now = new Date(); const periodStart = new Date(now); const periodEnd = new Date(now); @@ -278,7 +296,7 @@ export class GasSubsidyService { walletAddress, acknowledged: false, }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, }); if (existingAlert && existingAlert.usagePercentage >= cap.usagePercentage) { @@ -319,17 +337,22 @@ export class GasSubsidyService { return this.alertRepository.find({ where, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, }); } /** * Acknowledge an alert */ - async acknowledgeAlert(alertId: string, acknowledgedBy: string): Promise { - const alert = await this.alertRepository.findOne({ where: { id: alertId } }); + async acknowledgeAlert( + alertId: string, + acknowledgedBy: string, + ): Promise { + const alert = await this.alertRepository.findOne({ + where: { id: alertId }, + }); if (!alert) { - throw new Error('Alert not found'); + throw new Error("Alert not found"); } alert.acknowledged = true; @@ -348,7 +371,7 @@ export class GasSubsidyService { ): Promise { return this.usageLogRepository.find({ where: { walletAddress }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, }); } @@ -364,7 +387,7 @@ export class GasSubsidyService { return this.capRepository.find({ where, - order: { createdAt: 'DESC' }, + order: { createdAt: "DESC" }, }); } @@ -385,22 +408,35 @@ export class GasSubsidyService { }>; }> { const allCaps = await this.capRepository.find(); - - const activeWallets = allCaps.filter(c => c.status === SubsidyStatus.ACTIVE).length; - const warnedWallets = allCaps.filter(c => c.usagePercentage >= c.warningThreshold && c.usagePercentage < c.hardCap).length; - const exceededWallets = allCaps.filter(c => c.status === SubsidyStatus.EXCEEDED).length; - + + const activeWallets = allCaps.filter( + (c) => c.status === SubsidyStatus.ACTIVE, + ).length; + const warnedWallets = allCaps.filter( + (c) => + c.usagePercentage >= c.warningThreshold && + c.usagePercentage < c.hardCap, + ).length; + const exceededWallets = allCaps.filter( + (c) => c.status === SubsidyStatus.EXCEEDED, + ).length; + // Get flagged wallets count - const flaggedCount = await this.flagRepository.count({ where: { active: true } }); + const flaggedCount = await this.flagRepository.count({ + where: { active: true }, + }); // Calculate total usage - const totalUsage = allCaps.reduce((sum, c) => sum + Number(c.currentUsage), 0); + const totalUsage = allCaps.reduce( + (sum, c) => sum + Number(c.currentUsage), + 0, + ); // Get top consumers const topConsumers = allCaps .sort((a, b) => Number(b.currentUsage) - Number(a.currentUsage)) .slice(0, 10) - .map(c => ({ + .map((c) => ({ walletAddress: c.walletAddress, usage: Number(c.currentUsage), percentage: c.usagePercentage, @@ -427,14 +463,14 @@ export class GasSubsidyService { }> { const recentFlags = await this.flagRepository.find({ where: { walletAddress, active: true }, - order: { lastDetectedAt: 'DESC' }, + order: { lastDetectedAt: "DESC" }, }); if (recentFlags.length === 0) { return { isSuspicious: false, blocked: false, severity: 0 }; } - const highestSeverity = Math.max(...recentFlags.map(f => f.severity)); + const highestSeverity = Math.max(...recentFlags.map((f) => f.severity)); return { isSuspicious: true, blocked: highestSeverity >= 8, @@ -445,29 +481,43 @@ export class GasSubsidyService { /** * Analyze and flag suspicious usage */ - private async analyzeAndFlagSuspiciousUsage(record: SubsidyUsageRecord): Promise { + private async analyzeAndFlagSuspiciousUsage( + record: SubsidyUsageRecord, + ): Promise { const recentLogs = await this.usageLogRepository.find({ where: { walletAddress: record.walletAddress }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: 10, }); const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); - const recentCount = recentLogs.filter(l => new Date(l.timestamp) > oneHourAgo).length; + const recentCount = recentLogs.filter( + (l) => new Date(l.timestamp) > oneHourAgo, + ).length; // Flag if too many transactions in short period if (recentCount > 50) { - await this.flagUsage(record.walletAddress, 'rapid_transactions', - `Unusually high transaction rate: ${recentCount} transactions in the last hour`, 8); + await this.flagUsage( + record.walletAddress, + "rapid_transactions", + `Unusually high transaction rate: ${recentCount} transactions in the last hour`, + 8, + ); } // Flag if transaction amount is unusually high if (recentLogs.length > 0) { - const avgAmount = recentLogs.reduce((sum, l) => sum + Number(l.amount), 0) / recentLogs.length; + const avgAmount = + recentLogs.reduce((sum, l) => sum + Number(l.amount), 0) / + recentLogs.length; if (Number(record.amount) > avgAmount * 10) { - await this.flagUsage(record.walletAddress, 'unusual_amount', - `Transaction amount ${record.amount} is ${(Number(record.amount) / avgAmount).toFixed(1)}x the average`, 6); + await this.flagUsage( + record.walletAddress, + "unusual_amount", + `Transaction amount ${record.amount} is ${(Number(record.amount) / avgAmount).toFixed(1)}x the average`, + 6, + ); } } } @@ -508,7 +558,9 @@ export class GasSubsidyService { /** * Get active suspicious usage flags */ - async getSuspiciousFlags(walletAddress?: string): Promise { + async getSuspiciousFlags( + walletAddress?: string, + ): Promise { const where: any = { active: true }; if (walletAddress) { where.walletAddress = walletAddress; @@ -516,7 +568,7 @@ export class GasSubsidyService { return this.flagRepository.find({ where, - order: { severity: 'DESC', lastDetectedAt: 'DESC' }, + order: { severity: "DESC", lastDetectedAt: "DESC" }, }); } @@ -526,7 +578,7 @@ export class GasSubsidyService { async clearSuspiciousFlag(flagId: string): Promise { const flag = await this.flagRepository.findOne({ where: { id: flagId } }); if (!flag) { - throw new Error('Flag not found'); + throw new Error("Flag not found"); } flag.active = false; diff --git a/apps/api-service/src/gas/caching/__tests__/cache-config.spec.ts b/apps/api-service/src/gas/caching/__tests__/cache-config.spec.ts index 3bd04d2..15ae353 100644 --- a/apps/api-service/src/gas/caching/__tests__/cache-config.spec.ts +++ b/apps/api-service/src/gas/caching/__tests__/cache-config.spec.ts @@ -7,115 +7,115 @@ import { cacheKeys, getTTL, defaultCacheConfig, -} from '../cache-config'; +} from "../cache-config"; -describe('CacheConfig', () => { - describe('buildCacheKey', () => { - it('should build cache key with prefix', () => { - const key = buildCacheKey('base_fee', 1); - expect(key).toContain('gasguard:'); - expect(key).toContain('base_fee'); - expect(key).toContain('1'); +describe("CacheConfig", () => { + describe("buildCacheKey", () => { + it("should build cache key with prefix", () => { + const key = buildCacheKey("base_fee", 1); + expect(key).toContain("gasguard:"); + expect(key).toContain("base_fee"); + expect(key).toContain("1"); }); - it('should handle multiple parts', () => { - const key = buildCacheKey('gas', 'estimate', 1, 'endpoint'); - expect(key).toContain('gas:estimate:1:endpoint'); + it("should handle multiple parts", () => { + const key = buildCacheKey("gas", "estimate", 1, "endpoint"); + expect(key).toContain("gas:estimate:1:endpoint"); }); - it('should handle numeric and string parts', () => { - const key = buildCacheKey('test', 123, 'string', 456); - expect(key).toContain(':123:'); - expect(key).toContain(':string:'); - expect(key).toContain(':456'); + it("should handle numeric and string parts", () => { + const key = buildCacheKey("test", 123, "string", 456); + expect(key).toContain(":123:"); + expect(key).toContain(":string:"); + expect(key).toContain(":456"); }); }); - describe('cache keys builder', () => { - it('should build base fee cache key', () => { + describe("cache keys builder", () => { + it("should build base fee cache key", () => { const key = cacheKeys.baseFee(1); - expect(key).toContain('base_fee'); - expect(key).toContain('1'); + expect(key).toContain("base_fee"); + expect(key).toContain("1"); }); - it('should build priority fee cache key', () => { + it("should build priority fee cache key", () => { const key = cacheKeys.priorityFee(137); - expect(key).toContain('priority_fee'); - expect(key).toContain('137'); + expect(key).toContain("priority_fee"); + expect(key).toContain("137"); }); - it('should build gas estimate cache key', () => { - const key = cacheKeys.gasEstimate(1, '/rpc/eth'); - expect(key).toContain('gas_estimate'); - expect(key).toContain('1'); - expect(key).toContain('/rpc/eth'); + it("should build gas estimate cache key", () => { + const key = cacheKeys.gasEstimate(1, "/rpc/eth"); + expect(key).toContain("gas_estimate"); + expect(key).toContain("1"); + expect(key).toContain("/rpc/eth"); }); - it('should build chain metrics cache key', () => { + it("should build chain metrics cache key", () => { const key = cacheKeys.chainMetrics(42161); - expect(key).toContain('chain_metrics'); - expect(key).toContain('42161'); + expect(key).toContain("chain_metrics"); + expect(key).toContain("42161"); }); - it('should build volatility cache key', () => { - const key = cacheKeys.volatility(1, '1h'); - expect(key).toContain('volatility'); - expect(key).toContain('1'); - expect(key).toContain('1h'); + it("should build volatility cache key", () => { + const key = cacheKeys.volatility(1, "1h"); + expect(key).toContain("volatility"); + expect(key).toContain("1"); + expect(key).toContain("1h"); }); }); - describe('getTTL', () => { - it('should return baseFee TTL', () => { - const ttl = getTTL('baseFee'); + describe("getTTL", () => { + it("should return baseFee TTL", () => { + const ttl = getTTL("baseFee"); expect(ttl).toBe(defaultCacheConfig.ttl.baseFee); expect(ttl).toBeGreaterThan(0); }); - it('should return priorityFee TTL', () => { - const ttl = getTTL('priorityFee'); + it("should return priorityFee TTL", () => { + const ttl = getTTL("priorityFee"); expect(ttl).toBe(defaultCacheConfig.ttl.priorityFee); expect(ttl).toBeGreaterThan(0); }); - it('should return gasEstimate TTL', () => { - const ttl = getTTL('gasEstimate'); + it("should return gasEstimate TTL", () => { + const ttl = getTTL("gasEstimate"); expect(ttl).toBe(defaultCacheConfig.ttl.gasEstimate); expect(ttl).toBeGreaterThan(0); }); - it('should return chainMetrics TTL', () => { - const ttl = getTTL('chainMetrics'); + it("should return chainMetrics TTL", () => { + const ttl = getTTL("chainMetrics"); expect(ttl).toBe(defaultCacheConfig.ttl.chainMetrics); expect(ttl).toBeGreaterThan(0); }); - it('should return volatilityData TTL', () => { - const ttl = getTTL('volatilityData'); + it("should return volatilityData TTL", () => { + const ttl = getTTL("volatilityData"); expect(ttl).toBe(defaultCacheConfig.ttl.volatilityData); expect(ttl).toBeGreaterThan(0); }); - it('should return default TTL for unknown type', () => { - const ttl = getTTL('unknownType'); + it("should return default TTL for unknown type", () => { + const ttl = getTTL("unknownType"); expect(ttl).toBe(defaultCacheConfig.ttl.default); }); - it('should have reasonable TTL values', () => { + it("should have reasonable TTL values", () => { const config = defaultCacheConfig.ttl; expect(config.priorityFee).toBeLessThanOrEqual(config.baseFee); expect(config.default).toBeGreaterThan(0); }); }); - describe('default config', () => { - it('should have valid Redis config', () => { + describe("default config", () => { + it("should have valid Redis config", () => { const config = defaultCacheConfig.redis; expect(config.host).toBeTruthy(); expect(config.port).toBeGreaterThan(0); }); - it('should have all TTL values', () => { + it("should have all TTL values", () => { const ttl = defaultCacheConfig.ttl; expect(ttl.baseFee).toBeGreaterThan(0); expect(ttl.priorityFee).toBeGreaterThan(0); @@ -125,18 +125,18 @@ describe('CacheConfig', () => { expect(ttl.default).toBeGreaterThan(0); }); - it('should have behavior config', () => { + it("should have behavior config", () => { const behavior = defaultCacheConfig.behavior; - expect(typeof behavior.enabled).toBe('boolean'); - expect(typeof behavior.keyPrefix).toBe('string'); + expect(typeof behavior.enabled).toBe("boolean"); + expect(typeof behavior.keyPrefix).toBe("string"); expect(behavior.maxRetries).toBeGreaterThan(0); }); }); - describe('env variable overrides', () => { - it('should load from environment variables', () => { + describe("env variable overrides", () => { + it("should load from environment variables", () => { const originalHost = process.env.REDIS_HOST; - process.env.REDIS_HOST = 'custom-redis'; + process.env.REDIS_HOST = "custom-redis"; // Note: In real test, would need to reimport module after setting env expect(defaultCacheConfig.redis.host).toBeTruthy(); diff --git a/apps/api-service/src/gas/caching/__tests__/cache-metrics.service.spec.ts b/apps/api-service/src/gas/caching/__tests__/cache-metrics.service.spec.ts index 6f07d8e..8b2c146 100644 --- a/apps/api-service/src/gas/caching/__tests__/cache-metrics.service.spec.ts +++ b/apps/api-service/src/gas/caching/__tests__/cache-metrics.service.spec.ts @@ -2,71 +2,71 @@ * Cache Metrics Service Tests */ /// -import { CacheMetricsService } from '../cache-metrics.service'; +import { CacheMetricsService } from "../cache-metrics.service"; -describe('CacheMetricsService', () => { +describe("CacheMetricsService", () => { let metricsService: CacheMetricsService; beforeEach(() => { metricsService = new CacheMetricsService(); }); - describe('recording hits and misses', () => { - it('should record cache hits', () => { - metricsService.recordHit('test:endpoint', 1, 10); + describe("recording hits and misses", () => { + it("should record cache hits", () => { + metricsService.recordHit("test:endpoint", 1, 10); const metrics = metricsService.getGlobalMetrics(); expect(metrics.hits).toBe(1); expect(metrics.totalRequests).toBe(1); }); - it('should record cache misses', () => { - metricsService.recordMiss('test:endpoint', 1, 50); + it("should record cache misses", () => { + metricsService.recordMiss("test:endpoint", 1, 50); const metrics = metricsService.getGlobalMetrics(); expect(metrics.misses).toBe(1); expect(metrics.totalRequests).toBe(1); }); - it('should calculate hit rate', () => { - metricsService.recordHit('endpoint1', 1, 10); - metricsService.recordHit('endpoint1', 1, 15); - metricsService.recordMiss('endpoint1', 1, 100); + it("should calculate hit rate", () => { + metricsService.recordHit("endpoint1", 1, 10); + metricsService.recordHit("endpoint1", 1, 15); + metricsService.recordMiss("endpoint1", 1, 100); const metrics = metricsService.getGlobalMetrics(); expect(metrics.hitRate).toBeCloseTo(66.67, 1); }); - it('should calculate average response time', () => { - metricsService.recordHit('endpoint1', 1, 10); - metricsService.recordHit('endpoint1', 1, 20); - metricsService.recordMiss('endpoint1', 1, 30); + it("should calculate average response time", () => { + metricsService.recordHit("endpoint1", 1, 10); + metricsService.recordHit("endpoint1", 1, 20); + metricsService.recordMiss("endpoint1", 1, 30); const metrics = metricsService.getGlobalMetrics(); expect(metrics.avgResponseTime).toBeCloseTo(20, 0); }); }); - describe('endpoint-specific metrics', () => { - it('should track per-endpoint metrics', () => { - metricsService.recordHit('base_fee', 1, 10); - metricsService.recordHit('base_fee', 1, 15); - metricsService.recordMiss('priority_fee', 1, 50); + describe("endpoint-specific metrics", () => { + it("should track per-endpoint metrics", () => { + metricsService.recordHit("base_fee", 1, 10); + metricsService.recordHit("base_fee", 1, 15); + metricsService.recordMiss("priority_fee", 1, 50); const endpointMetrics = metricsService.getEndpointMetrics(); - expect(endpointMetrics['base_fee'].hits).toBe(2); - expect(endpointMetrics['base_fee'].totalRequests).toBe(2); - expect(endpointMetrics['priority_fee'].misses).toBe(1); + expect(endpointMetrics["base_fee"].hits).toBe(2); + expect(endpointMetrics["base_fee"].totalRequests).toBe(2); + expect(endpointMetrics["priority_fee"].misses).toBe(1); }); - it('should calculate per-endpoint hit rate', () => { - metricsService.recordHit('endpoint1', 1, 10); - metricsService.recordHit('endpoint1', 1, 10); - metricsService.recordMiss('endpoint1', 1, 100); + it("should calculate per-endpoint hit rate", () => { + metricsService.recordHit("endpoint1", 1, 10); + metricsService.recordHit("endpoint1", 1, 10); + metricsService.recordMiss("endpoint1", 1, 100); const endpointMetrics = metricsService.getEndpointMetrics(); - const endpoint1 = endpointMetrics['endpoint1']; + const endpoint1 = endpointMetrics["endpoint1"]; expect(endpoint1.hits).toBe(2); expect(endpoint1.misses).toBe(1); @@ -74,11 +74,11 @@ describe('CacheMetricsService', () => { }); }); - describe('chain-specific metrics', () => { - it('should track per-chain metrics', () => { - metricsService.recordHit('base_fee', 1, 10); - metricsService.recordHit('base_fee', 1, 15); - metricsService.recordMiss('priority_fee', 137, 50); + describe("chain-specific metrics", () => { + it("should track per-chain metrics", () => { + metricsService.recordHit("base_fee", 1, 10); + metricsService.recordHit("base_fee", 1, 15); + metricsService.recordMiss("priority_fee", 137, 50); const chain1Metrics = metricsService.getChainMetrics(1) as any; const chain137Metrics = metricsService.getChainMetrics(137) as any; @@ -87,9 +87,9 @@ describe('CacheMetricsService', () => { expect(chain137Metrics.misses).toBe(1); }); - it('should return all chain metrics', () => { - metricsService.recordHit('base_fee', 1, 10); - metricsService.recordMiss('priority_fee', 137, 50); + it("should return all chain metrics", () => { + metricsService.recordHit("base_fee", 1, 10); + metricsService.recordMiss("priority_fee", 137, 50); const allMetrics = metricsService.getChainMetrics(); expect(allMetrics).toBeInstanceOf(Map); @@ -97,10 +97,10 @@ describe('CacheMetricsService', () => { }); }); - describe('metrics reset', () => { - it('should reset all metrics', () => { - metricsService.recordHit('endpoint1', 1, 10); - metricsService.recordMiss('endpoint1', 1, 50); + describe("metrics reset", () => { + it("should reset all metrics", () => { + metricsService.recordHit("endpoint1", 1, 10); + metricsService.recordMiss("endpoint1", 1, 50); let metrics = metricsService.getGlobalMetrics(); expect(metrics.totalRequests).toBe(2); @@ -114,15 +114,20 @@ describe('CacheMetricsService', () => { }); }); - describe('edge cases', () => { - it('should handle divide by zero in hit rate', () => { + describe("edge cases", () => { + it("should handle divide by zero in hit rate", () => { const metrics = metricsService.getGlobalMetrics(); expect(metrics.hitRate).toBeDefined(); - expect(typeof metrics.hitRate).toBe('number'); + expect(typeof metrics.hitRate).toBe("number"); }); - it('should handle multiple endpoint tracking', () => { - const endpoints = ['base_fee', 'priority_fee', 'gas_estimate', 'chain_metrics']; + it("should handle multiple endpoint tracking", () => { + const endpoints = [ + "base_fee", + "priority_fee", + "gas_estimate", + "chain_metrics", + ]; for (const endpoint of endpoints) { metricsService.recordHit(endpoint, 1, 10); @@ -133,12 +138,12 @@ describe('CacheMetricsService', () => { expect(Object.keys(endpointMetrics).length).toBe(4); }); - it('should track metrics for multiple chains', () => { + it("should track metrics for multiple chains", () => { const chains = [1, 137, 8453, 42161]; for (const chainId of chains) { - metricsService.recordHit('base_fee', chainId, 10); - metricsService.recordMiss('base_fee', chainId, 50); + metricsService.recordHit("base_fee", chainId, 10); + metricsService.recordMiss("base_fee", chainId, 50); } const allMetrics = metricsService.getChainMetrics(); diff --git a/apps/api-service/src/gas/caching/__tests__/cache.service.spec.ts b/apps/api-service/src/gas/caching/__tests__/cache.service.spec.ts index 5757abf..302b56d 100644 --- a/apps/api-service/src/gas/caching/__tests__/cache.service.spec.ts +++ b/apps/api-service/src/gas/caching/__tests__/cache.service.spec.ts @@ -2,11 +2,11 @@ * Cache Service Tests */ /// -import { CacheService } from '../cache.service'; -import { CacheMetricsService } from '../cache-metrics.service'; -import { RedisClient } from '../redis.client'; +import { CacheService } from "../cache.service"; +import { CacheMetricsService } from "../cache-metrics.service"; +import { RedisClient } from "../redis.client"; -describe('CacheService', () => { +describe("CacheService", () => { let cacheService: CacheService; let metricsService: CacheMetricsService; @@ -20,10 +20,10 @@ describe('CacheService', () => { await cacheService.clearAll(); }); - describe('getOrFetch', () => { - it('should return cached value on hit', async () => { - const key = 'test:key'; - const value = { baseFee: '50 gwei' }; + describe("getOrFetch", () => { + it("should return cached value on hit", async () => { + const key = "test:key"; + const value = { baseFee: "50 gwei" }; let fetcherCalled = false; const fetcher = async () => { fetcherCalled = true; @@ -34,84 +34,89 @@ describe('CacheService', () => { await cacheService.set(key, value, 300); // Get from cache - const result = await cacheService.getOrFetch(key, 'baseFee', fetcher); + const result = await cacheService.getOrFetch(key, "baseFee", fetcher); expect(result).toEqual(value); expect(fetcherCalled).toBe(false); }); - it('should fetch on cache miss', async () => { - const key = 'test:key:miss'; - const value = { priorityFee: '30 gwei' }; + it("should fetch on cache miss", async () => { + const key = "test:key:miss"; + const value = { priorityFee: "30 gwei" }; let fetcherCalled = false; const fetcher = async () => { fetcherCalled = true; return value; }; - const result = await cacheService.getOrFetch(key, 'priorityFee', fetcher, 1); + const result = await cacheService.getOrFetch( + key, + "priorityFee", + fetcher, + 1, + ); expect(result).toEqual(value); expect(fetcherCalled).toBe(true); }); - it('should cache fetched value', async () => { - const key = 'test:fetch:cache'; + it("should cache fetched value", async () => { + const key = "test:fetch:cache"; const value = { gasEstimate: 100000 }; const fetcher = async () => value; - await cacheService.getOrFetch(key, 'gasEstimate', fetcher, 1); + await cacheService.getOrFetch(key, "gasEstimate", fetcher, 1); const cached = await cacheService.get(key); expect(cached).toEqual(value); }); - it('should record cache metrics', async () => { - const key1 = 'test:metric:1'; - const key2 = 'test:metric:2'; - const value = { data: 'test' }; + it("should record cache metrics", async () => { + const key1 = "test:metric:1"; + const key2 = "test:metric:2"; + const value = { data: "test" }; const fetcher = async () => value; // Hit await cacheService.set(key1, value, 300); - await cacheService.getOrFetch(key1, 'baseFee', fetcher, 1); + await cacheService.getOrFetch(key1, "baseFee", fetcher, 1); // Miss - await cacheService.getOrFetch(key2, 'priorityFee', fetcher, 1); + await cacheService.getOrFetch(key2, "priorityFee", fetcher, 1); const metrics = metricsService.getGlobalMetrics(); expect(metrics.hits).toBeGreaterThan(0); expect(metrics.misses).toBeGreaterThan(0); }); - it('should handle fetcher errors', async () => { - const key = 'test:error'; - const error = new Error('RPC error'); + it("should handle fetcher errors", async () => { + const key = "test:error"; + const error = new Error("RPC error"); const fetcher = async () => { throw error; }; - await expect(cacheService.getOrFetch(key, 'baseFee', fetcher)).rejects.toThrow( - 'RPC error', - ); + await expect( + cacheService.getOrFetch(key, "baseFee", fetcher), + ).rejects.toThrow("RPC error"); }); - it('should respect TTL configuration', async () => { - const key = 'test:ttl'; - const value = { data: 'test' }; + it("should respect TTL configuration", async () => { + const key = "test:ttl"; + const value = { data: "test" }; const fetcher = async () => value; - await cacheService.getOrFetch(key, 'baseFee', fetcher, 1); + await cacheService.getOrFetch(key, "baseFee", fetcher, 1); const ttlConfig = cacheService.getTTLConfig(); expect(ttlConfig.baseFee).toBeGreaterThan(0); }); }); - describe('invalidation', () => { - it('should invalidate single key', async () => { - const key = 'test:invalidate'; - const value = { data: 'test' }; + describe("invalidation", () => { + it("should invalidate single key", async () => { + const key = "test:invalidate"; + const value = { data: "test" }; await cacheService.set(key, value, 300); let cached = await cacheService.get(key); @@ -122,14 +127,18 @@ describe('CacheService', () => { expect(cached).toBeNull(); }); - it('should invalidate chain', async () => { + it("should invalidate chain", async () => { const chainId = 1; - const prefixes = ['gasguard:base_fee', 'gasguard:priority_fee', 'gasguard:gas_estimate']; + const prefixes = [ + "gasguard:base_fee", + "gasguard:priority_fee", + "gasguard:gas_estimate", + ]; // Set multiple cache entries for (const prefix of prefixes) { const key = `${prefix}:${chainId}`; - await cacheService.set(key, { data: 'test' }, 300); + await cacheService.set(key, { data: "test" }, 300); } // Invalidate chain @@ -137,11 +146,11 @@ describe('CacheService', () => { expect(count).toBeGreaterThan(0); }); - it('should clear all cache', async () => { - const keys = ['test:1', 'test:2', 'test:3']; + it("should clear all cache", async () => { + const keys = ["test:1", "test:2", "test:3"]; for (const key of keys) { - await cacheService.set(key, { data: 'test' }, 300); + await cacheService.set(key, { data: "test" }, 300); } await cacheService.clearAll(); @@ -153,26 +162,26 @@ describe('CacheService', () => { }); }); - describe('health status', () => { - it('should report cache health', async () => { + describe("health status", () => { + it("should report cache health", async () => { const health = await cacheService.getHealthStatus(); - expect(health).toHaveProperty('connected'); - expect(health).toHaveProperty('enabled'); - expect(typeof health.connected).toBe('boolean'); - expect(typeof health.enabled).toBe('boolean'); + expect(health).toHaveProperty("connected"); + expect(health).toHaveProperty("enabled"); + expect(typeof health.connected).toBe("boolean"); + expect(typeof health.enabled).toBe("boolean"); }); - it('should report availability', () => { + it("should report availability", () => { const available = cacheService.isAvailable(); - expect(typeof available).toBe('boolean'); + expect(typeof available).toBe("boolean"); }); }); - describe('manual cache operations', () => { - it('should set and get values', async () => { - const key = 'test:manual'; - const value = { chainId: 1, baseFee: '50 gwei' }; + describe("manual cache operations", () => { + it("should set and get values", async () => { + const key = "test:manual"; + const value = { chainId: 1, baseFee: "50 gwei" }; await cacheService.set(key, value, 300); const retrieved = await cacheService.get(key); @@ -180,15 +189,15 @@ describe('CacheService', () => { expect(retrieved).toEqual(value); }); - it('should return null for non-existent keys', async () => { - const value = await cacheService.get('non:existent:key'); + it("should return null for non-existent keys", async () => { + const value = await cacheService.get("non:existent:key"); expect(value).toBeNull(); }); - it('should handle serialization', async () => { - const key = 'test:serialize'; + it("should handle serialization", async () => { + const key = "test:serialize"; const value = { - baseFee: '50', + baseFee: "50", timestamp: new Date().toISOString(), nested: { deep: { value: 123 } }, }; diff --git a/apps/api-service/src/gas/caching/cache-config.ts b/apps/api-service/src/gas/caching/cache-config.ts index 06974da..38dec9d 100644 --- a/apps/api-service/src/gas/caching/cache-config.ts +++ b/apps/api-service/src/gas/caching/cache-config.ts @@ -16,20 +16,20 @@ export interface CacheConfig { // Cache TTL (time-to-live) in seconds ttl: { - baseFee: number; // Usually stable per block, 1-5 min - priorityFee: number; // More volatile, 30-60 sec - gasEstimate: number; // Stable, 2-5 min - chainMetrics: number; // Stable, 5-10 min - volatilityData: number; // Historical, 10-30 min - default: number; // Fallback TTL + baseFee: number; // Usually stable per block, 1-5 min + priorityFee: number; // More volatile, 30-60 sec + gasEstimate: number; // Stable, 2-5 min + chainMetrics: number; // Stable, 5-10 min + volatilityData: number; // Historical, 10-30 min + default: number; // Fallback TTL }; // Cache behavior behavior: { enabled: boolean; staleWhileRevalidate?: number; // Serve stale data while refreshing (sec) - maxRetries?: number; // Redis connection retries - keyPrefix?: string; // Cache key namespace + maxRetries?: number; // Redis connection retries + keyPrefix?: string; // Cache key namespace }; } @@ -38,27 +38,27 @@ export interface CacheConfig { */ export const defaultCacheConfig: CacheConfig = { redis: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT || "6379", 10), password: process.env.REDIS_PASSWORD, - db: parseInt(process.env.REDIS_DB || '0', 10), + db: parseInt(process.env.REDIS_DB || "0", 10), lazyConnect: true, enableReadyCheck: true, enableOfflineQueue: false, }, ttl: { - baseFee: parseInt(process.env.CACHE_TTL_BASE_FEE || '120', 10), - priorityFee: parseInt(process.env.CACHE_TTL_PRIORITY_FEE || '60', 10), - gasEstimate: parseInt(process.env.CACHE_TTL_GAS_ESTIMATE || '180', 10), - chainMetrics: parseInt(process.env.CACHE_TTL_CHAIN_METRICS || '300', 10), - volatilityData: parseInt(process.env.CACHE_TTL_VOLATILITY || '600', 10), - default: parseInt(process.env.CACHE_TTL_DEFAULT || '180', 10), + baseFee: parseInt(process.env.CACHE_TTL_BASE_FEE || "120", 10), + priorityFee: parseInt(process.env.CACHE_TTL_PRIORITY_FEE || "60", 10), + gasEstimate: parseInt(process.env.CACHE_TTL_GAS_ESTIMATE || "180", 10), + chainMetrics: parseInt(process.env.CACHE_TTL_CHAIN_METRICS || "300", 10), + volatilityData: parseInt(process.env.CACHE_TTL_VOLATILITY || "600", 10), + default: parseInt(process.env.CACHE_TTL_DEFAULT || "180", 10), }, behavior: { - enabled: process.env.CACHE_ENABLED !== 'false', - staleWhileRevalidate: parseInt(process.env.CACHE_STALE_TTL || '30', 10), + enabled: process.env.CACHE_ENABLED !== "false", + staleWhileRevalidate: parseInt(process.env.CACHE_STALE_TTL || "30", 10), maxRetries: 3, - keyPrefix: 'gasguard:', + keyPrefix: "gasguard:", }, }; @@ -66,21 +66,21 @@ export const defaultCacheConfig: CacheConfig = { * Build Redis cache key from parts */ export function buildCacheKey(...parts: (string | number)[]): string { - const prefix = defaultCacheConfig.behavior.keyPrefix || 'gasguard:'; - return `${prefix}${parts.join(':')}`; + const prefix = defaultCacheConfig.behavior.keyPrefix || "gasguard:"; + return `${prefix}${parts.join(":")}`; } /** * Cache key builders for different query types */ export const cacheKeys = { - baseFee: (chainId: number) => buildCacheKey('base_fee', chainId), - priorityFee: (chainId: number) => buildCacheKey('priority_fee', chainId), + baseFee: (chainId: number) => buildCacheKey("base_fee", chainId), + priorityFee: (chainId: number) => buildCacheKey("priority_fee", chainId), gasEstimate: (chainId: number, endpoint: string) => - buildCacheKey('gas_estimate', chainId, endpoint), - chainMetrics: (chainId: number) => buildCacheKey('chain_metrics', chainId), + buildCacheKey("gas_estimate", chainId, endpoint), + chainMetrics: (chainId: number) => buildCacheKey("chain_metrics", chainId), volatility: (chainId: number, period: string) => - buildCacheKey('volatility', chainId, period), + buildCacheKey("volatility", chainId, period), }; /** diff --git a/apps/api-service/src/gas/caching/cache-metrics.service.ts b/apps/api-service/src/gas/caching/cache-metrics.service.ts index 138e8b0..2b3596d 100644 --- a/apps/api-service/src/gas/caching/cache-metrics.service.ts +++ b/apps/api-service/src/gas/caching/cache-metrics.service.ts @@ -2,7 +2,7 @@ * Cache Metrics Service * Tracks cache hit/miss rates and performance metrics */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from "@nestjs/common"; export interface CacheMetrics { hits: number; @@ -23,7 +23,7 @@ export interface EndpointMetrics { @Injectable() export class CacheMetricsService { - private logger = new Logger('CacheMetricsService'); + private logger = new Logger("CacheMetricsService"); private globalMetrics = { hits: 0, misses: 0, @@ -106,7 +106,8 @@ export class CacheMetricsService { } m.totalRequests++; m.avgResponseTime = - (m.avgResponseTime * (m.totalRequests - 1) + responseTime) / m.totalRequests; + (m.avgResponseTime * (m.totalRequests - 1) + responseTime) / + m.totalRequests; } /** @@ -120,9 +121,7 @@ export class CacheMetricsService { hitRate: Math.round((this.globalMetrics.hits / total) * 100 * 100) / 100, totalRequests: total, avgResponseTime: - Math.round( - (this.globalMetrics.totalResponseTime / total) * 100, - ) / 100, + Math.round((this.globalMetrics.totalResponseTime / total) * 100) / 100, }; } @@ -170,6 +169,8 @@ export class CacheMetricsService { */ logMetrics(): void { const global = this.getGlobalMetrics(); - this.logger.log(`Cache Metrics - Hits: ${global.hits}, Misses: ${global.misses}, Hit Rate: ${global.hitRate}%`); + this.logger.log( + `Cache Metrics - Hits: ${global.hits}, Misses: ${global.misses}, Hit Rate: ${global.hitRate}%`, + ); } } diff --git a/apps/api-service/src/gas/caching/cache.decorator.ts b/apps/api-service/src/gas/caching/cache.decorator.ts index 05713fd..7f46f96 100644 --- a/apps/api-service/src/gas/caching/cache.decorator.ts +++ b/apps/api-service/src/gas/caching/cache.decorator.ts @@ -2,8 +2,8 @@ * Cache Decorator * Decorator for caching method results */ -import { CacheService } from './cache.service'; -import { cacheKeys, getTTL } from './cache-config'; +import { CacheService } from "./cache.service"; +import { cacheKeys, getTTL } from "./cache-config"; /** * Decorator to cache method results @@ -14,7 +14,11 @@ export function Cacheable( queryType: string, keyBuilder?: (args: any[]) => string, ) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { @@ -25,7 +29,9 @@ export function Cacheable( } // Build cache key - const key = keyBuilder ? keyBuilder(args) : `${propertyKey}:${JSON.stringify(args)}`; + const key = keyBuilder + ? keyBuilder(args) + : `${propertyKey}:${JSON.stringify(args)}`; // Get or fetch return cacheService.getOrFetch( @@ -44,8 +50,14 @@ export function Cacheable( * Decorator to invalidate cache * @param keyPatterns - Patterns to invalidate (can use chainId from args) */ -export function InvalidateCache(keyPatterns: (args: any[]) => string | string[]) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { +export function InvalidateCache( + keyPatterns: (args: any[]) => string | string[], +) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { diff --git a/apps/api-service/src/gas/caching/cache.module.ts b/apps/api-service/src/gas/caching/cache.module.ts index 7be005a..4a4a3a9 100644 --- a/apps/api-service/src/gas/caching/cache.module.ts +++ b/apps/api-service/src/gas/caching/cache.module.ts @@ -2,9 +2,9 @@ * Cache Module * Integrates caching into the application */ -import { Module } from '@nestjs/common'; -import { CacheService } from './cache.service'; -import { CacheMetricsService } from './cache-metrics.service'; +import { Module } from "@nestjs/common"; +import { CacheService } from "./cache.service"; +import { CacheMetricsService } from "./cache-metrics.service"; export interface OnModuleInit { onModuleInit(): Promise; diff --git a/apps/api-service/src/gas/caching/cache.service.ts b/apps/api-service/src/gas/caching/cache.service.ts index 91ff2d7..d8f7df7 100644 --- a/apps/api-service/src/gas/caching/cache.service.ts +++ b/apps/api-service/src/gas/caching/cache.service.ts @@ -2,14 +2,14 @@ * Cache Service * Core caching logic with RPC fallback */ -import { Injectable, Logger } from '@nestjs/common'; -import { RedisClient } from './redis.client'; -import { CacheMetricsService } from './cache-metrics.service'; -import { CacheConfig, getTTL, defaultCacheConfig } from './cache-config'; +import { Injectable, Logger } from "@nestjs/common"; +import { RedisClient } from "./redis.client"; +import { CacheMetricsService } from "./cache-metrics.service"; +import { CacheConfig, getTTL, defaultCacheConfig } from "./cache-config"; @Injectable() export class CacheService { - private logger = new Logger('CacheService'); + private logger = new Logger("CacheService"); private redis: RedisClient; private config: CacheConfig; @@ -23,7 +23,7 @@ export class CacheService { */ async initialize(): Promise { await this.redis.connect(); - this.logger.log('Cache service initialized'); + this.logger.log("Cache service initialized"); } /** @@ -120,7 +120,9 @@ export class CacheService { this.logger.debug(`Invalidated ${count} cache keys matching ${pattern}`); return count; } catch (error) { - this.logger.error(`Failed to invalidate pattern ${pattern}: ${error.message}`); + this.logger.error( + `Failed to invalidate pattern ${pattern}: ${error.message}`, + ); return 0; } } @@ -159,7 +161,7 @@ export class CacheService { */ async clearAll(): Promise { await this.redis.flush(); - this.logger.log('All cache cleared'); + this.logger.log("All cache cleared"); } /** diff --git a/apps/api-service/src/gas/caching/index.ts b/apps/api-service/src/gas/caching/index.ts index 293a263..0a90372 100644 --- a/apps/api-service/src/gas/caching/index.ts +++ b/apps/api-service/src/gas/caching/index.ts @@ -1,15 +1,19 @@ /** * Caching Module Export Index */ -export { CacheService } from './cache.service'; -export { CacheMetricsService } from './cache-metrics.service'; -export { CacheModule } from './cache.module'; +export { CacheService } from "./cache.service"; +export { CacheMetricsService } from "./cache-metrics.service"; +export { CacheModule } from "./cache.module"; export { CacheConfig, defaultCacheConfig, cacheKeys, buildCacheKey, getTTL, -} from './cache-config'; -export { Cacheable, InvalidateCache, cacheKeyBuilders } from './cache.decorator'; -export { RedisClient } from './redis.client'; +} from "./cache-config"; +export { + Cacheable, + InvalidateCache, + cacheKeyBuilders, +} from "./cache.decorator"; +export { RedisClient } from "./redis.client"; diff --git a/apps/api-service/src/gas/caching/integration.example.ts b/apps/api-service/src/gas/caching/integration.example.ts index 4998651..771aff7 100644 --- a/apps/api-service/src/gas/caching/integration.example.ts +++ b/apps/api-service/src/gas/caching/integration.example.ts @@ -1,9 +1,9 @@ /** * Integration Example: Gas Endpoints with Caching */ -import { Injectable } from '@nestjs/common'; -import { CacheService, cacheKeys, cacheKeyBuilders } from './index'; -import { Cacheable } from './cache.decorator'; +import { Injectable } from "@nestjs/common"; +import { CacheService, cacheKeys, cacheKeyBuilders } from "./index"; +import { Cacheable } from "./cache.decorator"; /** * Example RPC Client (placeholder) @@ -11,7 +11,7 @@ import { Cacheable } from './cache.decorator'; class RPCClient { async call(chainId: number, method: string, params: any[]): Promise { // Simulated RPC call - return { result: 'mock_value' }; + return { result: "mock_value" }; } } @@ -33,7 +33,7 @@ export class GasServiceWithCaching { return this.cache.getOrFetch( key, - 'baseFee', + "baseFee", () => this.fetchBaseFeeFromRPC(chainId), chainId, ); @@ -47,7 +47,7 @@ export class GasServiceWithCaching { return this.cache.getOrFetch( key, - 'priorityFee', + "priorityFee", () => this.fetchPriorityFeeFromRPC(chainId), chainId, ); @@ -66,7 +66,7 @@ export class GasServiceWithCaching { return this.cache.getOrFetch( key, - 'gasEstimate', + "gasEstimate", () => this.estimateGasFromRPC(chainId, toAddress, data), chainId, ); @@ -80,7 +80,7 @@ export class GasServiceWithCaching { return this.cache.getOrFetch( key, - 'chainMetrics', + "chainMetrics", () => this.fetchChainMetricsFromRPC(chainId), chainId, ); @@ -91,13 +91,13 @@ export class GasServiceWithCaching { */ async getVolatilityData( chainId: number, - period: string = '1h', + period: string = "1h", ): Promise { const key = cacheKeys.volatility(chainId, period); return this.cache.getOrFetch( key, - 'volatilityData', + "volatilityData", () => this.fetchVolatilityDataFromRPC(chainId, period), chainId, ); @@ -123,7 +123,7 @@ export class GasServiceWithCaching { private async fetchBaseFeeFromRPC(chainId: number): Promise { const response = await this.rpcClient.call( chainId, - 'eth_baseFeePerGas', + "eth_baseFeePerGas", [], ); return response.result; @@ -132,7 +132,7 @@ export class GasServiceWithCaching { private async fetchPriorityFeeFromRPC(chainId: number): Promise { const response = await this.rpcClient.call( chainId, - 'eth_maxPriorityFeePerGas', + "eth_maxPriorityFeePerGas", [], ); return response.result; @@ -143,16 +143,12 @@ export class GasServiceWithCaching { toAddress: string, data?: string, ): Promise { - const response = await this.rpcClient.call( - chainId, - 'eth_estimateGas', - [ - { - to: toAddress, - data, - }, - ], - ); + const response = await this.rpcClient.call(chainId, "eth_estimateGas", [ + { + to: toAddress, + data, + }, + ]); return parseInt(response.result, 16); } @@ -162,7 +158,7 @@ export class GasServiceWithCaching { chainId, avgBlockTime: 12.5, txPerSecond: 100.5, - avgGasPrice: '50 gwei', + avgGasPrice: "50 gwei", }; } @@ -175,8 +171,8 @@ export class GasServiceWithCaching { chainId, period, volatility: 15.2, - minGasPrice: '20 gwei', - maxGasPrice: '200 gwei', + minGasPrice: "20 gwei", + maxGasPrice: "200 gwei", }; } } @@ -203,11 +199,7 @@ export class GasControllerCachingExample { } // GET /gas/estimate/1?to=0x1234&data=0x5678 - async getGasEstimate( - chainId: number, - toAddress: string, - data?: string, - ) { + async getGasEstimate(chainId: number, toAddress: string, data?: string) { const gas = await this.gasService.getGasEstimate(chainId, toAddress, data); return { chainId, gas }; } @@ -219,7 +211,7 @@ export class GasControllerCachingExample { } // GET /gas/volatility/1?period=1h - async getVolatility(chainId: number, period: string = '1h') { + async getVolatility(chainId: number, period: string = "1h") { const volatility = await this.gasService.getVolatilityData(chainId, period); return { chainId, period, volatility }; } @@ -248,7 +240,7 @@ export class GasControllerCachingExample { // DELETE /cache (admin only) async clearCache() { await this.cache.clearAll(); - return { message: 'Cache cleared' }; + return { message: "Cache cleared" }; } } diff --git a/apps/api-service/src/gas/caching/redis.client.ts b/apps/api-service/src/gas/caching/redis.client.ts index 98f818e..d295872 100644 --- a/apps/api-service/src/gas/caching/redis.client.ts +++ b/apps/api-service/src/gas/caching/redis.client.ts @@ -2,13 +2,13 @@ * Redis Client Manager * Handles Redis connection, reconnection, and error handling */ -import { Logger } from '@nestjs/common'; -import { CacheConfig, defaultCacheConfig } from './cache-config'; +import { Logger } from "@nestjs/common"; +import { CacheConfig, defaultCacheConfig } from "./cache-config"; export class RedisClient { private static instance: RedisClient; private redis: any = null; - private logger = new Logger('RedisClient'); + private logger = new Logger("RedisClient"); private connected = false; private retryCount = 0; private config: CacheConfig; @@ -43,7 +43,7 @@ export class RedisClient { const ioredis = eval("require('ioredis')"); Redis = ioredis.default || ioredis; } catch { - this.logger.warn('ioredis not available, using in-memory fallback'); + this.logger.warn("ioredis not available, using in-memory fallback"); this.redis = new InMemoryRedis(); this.connected = true; return; @@ -51,21 +51,23 @@ export class RedisClient { this.redis = new Redis(this.config.redis); - this.redis.on('connect', () => { - this.logger.log('Redis connected successfully'); + this.redis.on("connect", () => { + this.logger.log("Redis connected successfully"); this.connected = true; this.retryCount = 0; }); - this.redis.on('error', (err: Error) => { + this.redis.on("error", (err: Error) => { this.logger.error(`Redis error: ${err.message}`); this.connected = false; }); - this.redis.on('reconnecting', () => { + this.redis.on("reconnecting", () => { this.retryCount++; if (this.retryCount > (this.config.behavior.maxRetries || 3)) { - this.logger.warn('Max Redis retries exceeded, falling back to in-memory cache'); + this.logger.warn( + "Max Redis retries exceeded, falling back to in-memory cache", + ); this.redis = new InMemoryRedis(); this.connected = true; } @@ -75,7 +77,7 @@ export class RedisClient { this.connected = true; } catch (error) { this.logger.error(`Failed to connect to Redis: ${error.message}`); - this.logger.warn('Falling back to in-memory cache'); + this.logger.warn("Falling back to in-memory cache"); this.redis = new InMemoryRedis(); this.connected = true; } @@ -128,7 +130,9 @@ export class RedisClient { if (keys.length === 0) return 0; return await this.redis.del(...keys); } catch (error) { - this.logger.error(`Cache DELETE pattern failed for ${pattern}: ${error.message}`); + this.logger.error( + `Cache DELETE pattern failed for ${pattern}: ${error.message}`, + ); return 0; } } @@ -141,7 +145,9 @@ export class RedisClient { const result = await this.redis.exists(key); return result > 0; } catch (error) { - this.logger.error(`Cache EXISTS check failed for key ${key}: ${error.message}`); + this.logger.error( + `Cache EXISTS check failed for key ${key}: ${error.message}`, + ); return false; } } @@ -153,7 +159,9 @@ export class RedisClient { try { return await this.redis.ttl(key); } catch (error) { - this.logger.error(`Cache TTL check failed for key ${key}: ${error.message}`); + this.logger.error( + `Cache TTL check failed for key ${key}: ${error.message}`, + ); return -1; } } @@ -238,8 +246,8 @@ class InMemoryRedis { } async keys(pattern: string): Promise { - const regex = new RegExp(pattern.replace(/\*/g, '.*')); - return Array.from(this.store.keys()).filter(k => regex.test(k)); + const regex = new RegExp(pattern.replace(/\*/g, ".*")); + return Array.from(this.store.keys()).filter((k) => regex.test(k)); } async exists(key: string): Promise { @@ -255,7 +263,7 @@ class InMemoryRedis { } async flushdb(): Promise { - this.timers.forEach(timer => clearTimeout(timer)); + this.timers.forEach((timer) => clearTimeout(timer)); this.store.clear(); this.timers.clear(); } diff --git a/apps/api-service/src/health/health.controller.ts b/apps/api-service/src/health/health.controller.ts index 705c671..f99532e 100644 --- a/apps/api-service/src/health/health.controller.ts +++ b/apps/api-service/src/health/health.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get } from '@nestjs/common'; -import { HealthService } from './health.service'; -import { HealthCheckResponse } from './interfaces/health.interface'; +import { Controller, Get } from "@nestjs/common"; +import { HealthService } from "./health.service"; +import { HealthCheckResponse } from "./interfaces/health.interface"; -@Controller('health') +@Controller("health") export class HealthController { constructor(private readonly healthService: HealthService) {} @@ -11,12 +11,12 @@ export class HealthController { return this.healthService.check(); } - @Get('ready') + @Get("ready") readiness(): HealthCheckResponse { return this.healthService.checkReadiness(); } - @Get('live') + @Get("live") liveness(): HealthCheckResponse { return this.healthService.checkLiveness(); } diff --git a/apps/api-service/src/health/health.module.ts b/apps/api-service/src/health/health.module.ts index a38cb2c..39eff7f 100644 --- a/apps/api-service/src/health/health.module.ts +++ b/apps/api-service/src/health/health.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; -import { HealthService } from './health.service'; +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; +import { HealthService } from "./health.service"; @Module({ controllers: [HealthController], diff --git a/apps/api-service/src/health/health.service.ts b/apps/api-service/src/health/health.service.ts index f16f7be..b84284a 100644 --- a/apps/api-service/src/health/health.service.ts +++ b/apps/api-service/src/health/health.service.ts @@ -1,5 +1,8 @@ -import { Injectable } from '@nestjs/common'; -import { HealthCheckResponse, HealthStatus } from './interfaces/health.interface'; +import { Injectable } from "@nestjs/common"; +import { + HealthCheckResponse, + HealthStatus, +} from "./interfaces/health.interface"; @Injectable() export class HealthService { @@ -12,8 +15,8 @@ export class HealthService { check(): HealthCheckResponse { return { status: HealthStatus.HEALTHY, - service: 'gasguard-api', - version: '0.1.0', + service: "gasguard-api", + version: "0.1.0", timestamp: new Date().toISOString(), uptime: this.getUptime(), checks: { @@ -28,8 +31,8 @@ export class HealthService { checkReadiness(): HealthCheckResponse { return { status: HealthStatus.HEALTHY, - service: 'gasguard-api', - version: '0.1.0', + service: "gasguard-api", + version: "0.1.0", timestamp: new Date().toISOString(), uptime: this.getUptime(), checks: { @@ -41,8 +44,8 @@ export class HealthService { checkLiveness(): HealthCheckResponse { return { status: HealthStatus.HEALTHY, - service: 'gasguard-api', - version: '0.1.0', + service: "gasguard-api", + version: "0.1.0", timestamp: new Date().toISOString(), uptime: this.getUptime(), checks: { diff --git a/apps/api-service/src/health/index.ts b/apps/api-service/src/health/index.ts index 4a2ec2e..7b432bf 100644 --- a/apps/api-service/src/health/index.ts +++ b/apps/api-service/src/health/index.ts @@ -1,4 +1,4 @@ -export * from './health.module'; -export * from './health.controller'; -export * from './health.service'; -export * from './interfaces/health.interface'; +export * from "./health.module"; +export * from "./health.controller"; +export * from "./health.service"; +export * from "./interfaces/health.interface"; diff --git a/apps/api-service/src/health/interfaces/health.interface.ts b/apps/api-service/src/health/interfaces/health.interface.ts index f44da98..f5c155b 100644 --- a/apps/api-service/src/health/interfaces/health.interface.ts +++ b/apps/api-service/src/health/interfaces/health.interface.ts @@ -1,7 +1,7 @@ export enum HealthStatus { - HEALTHY = 'healthy', - UNHEALTHY = 'unhealthy', - DEGRADED = 'degraded', + HEALTHY = "healthy", + UNHEALTHY = "unhealthy", + DEGRADED = "degraded", } export interface HealthCheckResponse { diff --git a/apps/api-service/src/jest.d.ts b/apps/api-service/src/jest.d.ts index 7b2706c..0a283d0 100644 --- a/apps/api-service/src/jest.d.ts +++ b/apps/api-service/src/jest.d.ts @@ -27,7 +27,10 @@ declare global { mockResolvedValue(value: any): this; mockRejectedValue(value: any): this; } - interface SpyInstance extends Mock {} + interface SpyInstance extends Mock< + T, + U + > {} } function describe(name: string, fn: () => void): void; @@ -40,11 +43,13 @@ declare global { function expect(actual: T): jest.Matchers; const jest: { - fn any>(implementation?: T): jest.Mock, Parameters>; + fn any>( + implementation?: T, + ): jest.Mock, Parameters>; spyOn( object: T, method: P, - accessType?: 'get' | 'set' + accessType?: "get" | "set", ): jest.SpyInstance; useFakeTimers(): void; useRealTimers(): void; diff --git a/apps/api-service/src/main.ts b/apps/api-service/src/main.ts index fbef420..235d7a3 100644 --- a/apps/api-service/src/main.ts +++ b/apps/api-service/src/main.ts @@ -1,8 +1,8 @@ -import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; -import { AppModule } from './app.module'; -import { AuditInterceptor } from './audit/interceptors'; -import { AuditLogService } from './audit/services'; +import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { AppModule } from "./app.module"; +import { AuditInterceptor } from "./audit/interceptors"; +import { AuditLogService } from "./audit/services"; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -24,7 +24,9 @@ async function bootstrap() { const port = process.env.PORT || 3000; await app.listen(port); - console.log(`🚀 GasGuard API Service is running on: http://localhost:${port}`); + console.log( + `🚀 GasGuard API Service is running on: http://localhost:${port}`, + ); console.log(`📊 Health check available at: http://localhost:${port}/health`); } diff --git a/apps/api-service/src/node.d.ts b/apps/api-service/src/node.d.ts index 293aa78..31f5e41 100644 --- a/apps/api-service/src/node.d.ts +++ b/apps/api-service/src/node.d.ts @@ -1,4 +1,4 @@ -declare module 'node' { +declare module "node" { export interface Global { [key: string]: any; } @@ -6,7 +6,7 @@ declare module 'node' { declare namespace NodeJS { interface ProcessEnv { - NODE_ENV?: 'development' | 'production' | 'test'; + NODE_ENV?: "development" | "production" | "test"; [key: string]: string | undefined; } interface Process { diff --git a/apps/api-service/src/optimization/__tests__/optimization-engine.service.spec.ts b/apps/api-service/src/optimization/__tests__/optimization-engine.service.spec.ts index 94c0043..bd8ff6f 100644 --- a/apps/api-service/src/optimization/__tests__/optimization-engine.service.spec.ts +++ b/apps/api-service/src/optimization/__tests__/optimization-engine.service.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { OptimizationEngineService } from '../services/optimization-engine.service'; -import { DataAnalysisService } from '../services/data-analysis.service'; -import { OptimizationSuggestion } from '../entities/optimization-suggestion.entity'; +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { OptimizationEngineService } from "../services/optimization-engine.service"; +import { DataAnalysisService } from "../services/data-analysis.service"; +import { OptimizationSuggestion } from "../entities/optimization-suggestion.entity"; -describe('OptimizationEngineService', () => { +describe("OptimizationEngineService", () => { let service: OptimizationEngineService; let dataAnalysisService: DataAnalysisService; let optimizationSuggestionRepository: Repository; @@ -30,20 +30,20 @@ describe('OptimizationEngineService', () => { service = module.get(OptimizationEngineService); dataAnalysisService = module.get(DataAnalysisService); - optimizationSuggestionRepository = module.get>( - getRepositoryToken(OptimizationSuggestion) - ); + optimizationSuggestionRepository = module.get< + Repository + >(getRepositoryToken(OptimizationSuggestion)); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('generateOptimizationSuggestions', () => { - it('should generate optimization suggestions for a merchant', async () => { + describe("generateOptimizationSuggestions", () => { + it("should generate optimization suggestions for a merchant", async () => { // Mock data analysis service const mockAnalysis = { - merchantId: 'merchant-123', + merchantId: "merchant-123", transactionStats: { totalTransactions: 100, successfulTransactions: 90, @@ -56,8 +56,8 @@ describe('OptimizationEngineService', () => { }, chainBreakdown: [ { - chainId: 'ethereum', - chainName: 'Ethereum', + chainId: "ethereum", + chainName: "Ethereum", transactionCount: 60, totalGasUsed: 6000000, totalCostUSD: 60, @@ -65,8 +65,8 @@ describe('OptimizationEngineService', () => { successRate: 85, }, { - chainId: 'polygon', - chainName: 'Polygon', + chainId: "polygon", + chainName: "Polygon", transactionCount: 40, totalGasUsed: 2000000, totalCostUSD: 20, @@ -87,33 +87,41 @@ describe('OptimizationEngineService', () => { ], }; - jest.spyOn(dataAnalysisService, 'getTransactionAnalysis').mockResolvedValue(mockAnalysis); + jest + .spyOn(dataAnalysisService, "getTransactionAnalysis") + .mockResolvedValue(mockAnalysis); - const suggestions = await service.generateOptimizationSuggestions('merchant-123', 30); + const suggestions = await service.generateOptimizationSuggestions( + "merchant-123", + 30, + ); expect(suggestions).toBeDefined(); expect(Array.isArray(suggestions)).toBe(true); expect(suggestions.length).toBeGreaterThan(0); // Check that analysis service was called - expect(dataAnalysisService.getTransactionAnalysis).toHaveBeenCalledWith('merchant-123', 30); + expect(dataAnalysisService.getTransactionAnalysis).toHaveBeenCalledWith( + "merchant-123", + 30, + ); }); }); - describe('generateChainSwitchSuggestions', () => { - it('should generate chain switch suggestions when there are cost differences', () => { + describe("generateChainSwitchSuggestions", () => { + it("should generate chain switch suggestions when there are cost differences", () => { const mockAnalysis = { chainBreakdown: [ { - chainId: 'ethereum', - chainName: 'Ethereum', + chainId: "ethereum", + chainName: "Ethereum", avgCostPerTransaction: 1.5, transactionCount: 50, totalCostUSD: 75, }, { - chainId: 'polygon', - chainName: 'Polygon', + chainId: "polygon", + chainName: "Polygon", avgCostPerTransaction: 0.2, transactionCount: 30, totalCostUSD: 6, @@ -124,48 +132,50 @@ describe('OptimizationEngineService', () => { }, }; - const suggestions = (service as any).generateChainSwitchSuggestions(mockAnalysis); + const suggestions = (service as any).generateChainSwitchSuggestions( + mockAnalysis, + ); expect(suggestions).toBeDefined(); expect(Array.isArray(suggestions)).toBe(true); if (suggestions.length > 0) { - expect(suggestions[0].type).toBe('ChainSwitch'); - expect(suggestions[0].description).toContain('Switch'); + expect(suggestions[0].type).toBe("ChainSwitch"); + expect(suggestions[0].description).toContain("Switch"); expect(suggestions[0].estimatedSavingsUSD).toBeDefined(); expect(suggestions[0].priority).toBeGreaterThanOrEqual(1); } }); }); - describe('getOptimizationSummary', () => { - it('should return optimization summary for a merchant', async () => { + describe("getOptimizationSummary", () => { + it("should return optimization summary for a merchant", async () => { // Mock the getOptimizationSuggestions method - jest.spyOn(service, 'getOptimizationSuggestions').mockResolvedValue([ + jest.spyOn(service, "getOptimizationSuggestions").mockResolvedValue([ { - id: 'sug-1', - merchantId: 'merchant-123', - type: 'ChainSwitch', - description: 'Switch to cheaper chain', + id: "sug-1", + merchantId: "merchant-123", + type: "ChainSwitch", + description: "Switch to cheaper chain", estimatedSavingsUSD: 50, priority: 4, - status: 'pending', + status: "pending", createdAt: new Date(), updatedAt: new Date(), }, { - id: 'sug-2', - merchantId: 'merchant-123', - type: 'TimingAdjustment', - description: 'Adjust timing to low gas periods', + id: "sug-2", + merchantId: "merchant-123", + type: "TimingAdjustment", + description: "Adjust timing to low gas periods", estimatedSavingsUSD: 30, priority: 5, - status: 'applied', + status: "applied", createdAt: new Date(), updatedAt: new Date(), }, ] as any); - const summary = await service.getOptimizationSummary('merchant-123'); + const summary = await service.getOptimizationSummary("merchant-123"); expect(summary).toBeDefined(); expect(summary.totalPotentialSavingsUSD).toBe(50); // Only pending suggestions counted @@ -174,4 +184,4 @@ describe('OptimizationEngineService', () => { expect(summary.appliedSuggestions).toBe(1); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api-service/src/optimization/controllers/cost-optimization.controller.ts b/apps/api-service/src/optimization/controllers/cost-optimization.controller.ts index 213fffa..170483b 100644 --- a/apps/api-service/src/optimization/controllers/cost-optimization.controller.ts +++ b/apps/api-service/src/optimization/controllers/cost-optimization.controller.ts @@ -1,48 +1,78 @@ -import { Controller, Get, Param, Query, HttpCode, HttpStatus, Logger } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { OptimizationEngineService } from '../services/optimization-engine.service'; +import { + Controller, + Get, + Param, + Query, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, +} from "@nestjs/swagger"; +import { OptimizationEngineService } from "../services/optimization-engine.service"; -@ApiTags('Cost Optimization') -@Controller('merchant') +@ApiTags("Cost Optimization") +@Controller("merchant") export class CostOptimizationController { private readonly logger = new Logger(CostOptimizationController.name); - constructor(private readonly optimizationEngineService: OptimizationEngineService) {} + constructor( + private readonly optimizationEngineService: OptimizationEngineService, + ) {} - @Get(':merchantId/cost-optimization') - @ApiOperation({ summary: 'Get cost optimization suggestions for a merchant' }) - @ApiParam({ name: 'merchantId', description: 'ID of the merchant to get suggestions for', required: true }) - @ApiQuery({ name: 'days', description: 'Number of days back to analyze (default: 30)', required: false, type: Number }) - @ApiQuery({ name: 'status', description: 'Filter suggestions by status (pending, applied, rejected)', required: false, type: String }) - @ApiResponse({ - status: 200, - description: 'Cost optimization suggestions retrieved successfully', + @Get(":merchantId/cost-optimization") + @ApiOperation({ summary: "Get cost optimization suggestions for a merchant" }) + @ApiParam({ + name: "merchantId", + description: "ID of the merchant to get suggestions for", + required: true, + }) + @ApiQuery({ + name: "days", + description: "Number of days back to analyze (default: 30)", + required: false, + type: Number, + }) + @ApiQuery({ + name: "status", + description: "Filter suggestions by status (pending, applied, rejected)", + required: false, + type: String, + }) + @ApiResponse({ + status: 200, + description: "Cost optimization suggestions retrieved successfully", schema: { - type: 'object', + type: "object", properties: { - merchantId: { type: 'string' }, + merchantId: { type: "string" }, suggestions: { - type: 'array', + type: "array", items: { - type: 'object', + type: "object", properties: { - type: { type: 'string' }, - description: { type: 'string' }, - estimatedSavingsUSD: { type: 'number' }, - priority: { type: 'number' }, - category: { type: 'string' }, - metadata: { type: 'object' } - } - } - } - } - } + type: { type: "string" }, + description: { type: "string" }, + estimatedSavingsUSD: { type: "number" }, + priority: { type: "number" }, + category: { type: "string" }, + metadata: { type: "object" }, + }, + }, + }, + }, + }, }) @HttpCode(HttpStatus.OK) async getCostOptimizationSuggestions( - @Param('merchantId') merchantId: string, - @Query('days') days?: number, - @Query('status') status?: string, + @Param("merchantId") merchantId: string, + @Query("days") days?: number, + @Query("status") status?: string, ): Promise<{ merchantId: string; suggestions: Array<{ @@ -61,26 +91,40 @@ export class CostOptimizationController { }; }> { try { - this.logger.log(`Request to get cost optimization suggestions for merchant ${merchantId}`); + this.logger.log( + `Request to get cost optimization suggestions for merchant ${merchantId}`, + ); // Parse days parameter or use default const daysBack = days ? parseInt(days.toString(), 10) : 30; // Generate suggestions - const suggestions = await this.optimizationEngineService.generateOptimizationSuggestions(merchantId, daysBack); + const suggestions = + await this.optimizationEngineService.generateOptimizationSuggestions( + merchantId, + daysBack, + ); // Save suggestions to database - await this.optimizationEngineService.saveOptimizationSuggestions(merchantId, suggestions); + await this.optimizationEngineService.saveOptimizationSuggestions( + merchantId, + suggestions, + ); // Get all suggestions for this merchant (with optional status filter) - const allSuggestions = await this.optimizationEngineService.getOptimizationSuggestions(merchantId, status); + const allSuggestions = + await this.optimizationEngineService.getOptimizationSuggestions( + merchantId, + status, + ); // Get summary - const summary = await this.optimizationEngineService.getOptimizationSummary(merchantId); + const summary = + await this.optimizationEngineService.getOptimizationSummary(merchantId); return { merchantId, - suggestions: allSuggestions.map(s => ({ + suggestions: allSuggestions.map((s) => ({ type: s.type, description: s.description, estimatedSavingsUSD: s.estimatedSavingsUSD, @@ -88,33 +132,40 @@ export class CostOptimizationController { category: s.category, metadata: s.metadata, })), - summary + summary, }; } catch (error) { - this.logger.error(`Error getting cost optimization suggestions for merchant ${merchantId}`, error); + this.logger.error( + `Error getting cost optimization suggestions for merchant ${merchantId}`, + error, + ); throw error; } } - @Get(':merchantId/cost-optimization/summary') - @ApiOperation({ summary: 'Get cost optimization summary for a merchant' }) - @ApiParam({ name: 'merchantId', description: 'ID of the merchant to get summary for', required: true }) - @ApiResponse({ - status: 200, - description: 'Cost optimization summary retrieved successfully', + @Get(":merchantId/cost-optimization/summary") + @ApiOperation({ summary: "Get cost optimization summary for a merchant" }) + @ApiParam({ + name: "merchantId", + description: "ID of the merchant to get summary for", + required: true, + }) + @ApiResponse({ + status: 200, + description: "Cost optimization summary retrieved successfully", schema: { - type: 'object', + type: "object", properties: { - totalPotentialSavingsUSD: { type: 'number' }, - totalSuggestions: { type: 'number' }, - highPrioritySuggestions: { type: 'number' }, - appliedSuggestions: { type: 'number' } - } - } + totalPotentialSavingsUSD: { type: "number" }, + totalSuggestions: { type: "number" }, + highPrioritySuggestions: { type: "number" }, + appliedSuggestions: { type: "number" }, + }, + }, }) @HttpCode(HttpStatus.OK) async getCostOptimizationSummary( - @Param('merchantId') merchantId: string, + @Param("merchantId") merchantId: string, ): Promise<{ totalPotentialSavingsUSD: number; totalSuggestions: number; @@ -122,51 +173,75 @@ export class CostOptimizationController { appliedSuggestions: number; }> { try { - this.logger.log(`Request to get cost optimization summary for merchant ${merchantId}`); + this.logger.log( + `Request to get cost optimization summary for merchant ${merchantId}`, + ); - return await this.optimizationEngineService.getOptimizationSummary(merchantId); + return await this.optimizationEngineService.getOptimizationSummary( + merchantId, + ); } catch (error) { - this.logger.error(`Error getting cost optimization summary for merchant ${merchantId}`, error); + this.logger.error( + `Error getting cost optimization summary for merchant ${merchantId}`, + error, + ); throw error; } } - @Get(':merchantId/cost-optimization/history') - @ApiOperation({ summary: 'Get cost optimization suggestions history for a merchant' }) - @ApiParam({ name: 'merchantId', description: 'ID of the merchant to get history for', required: true }) - @ApiQuery({ name: 'status', description: 'Filter suggestions by status (pending, applied, rejected, archived)', required: false, type: String }) - @ApiQuery({ name: 'limit', description: 'Maximum number of suggestions to return (default: 10)', required: false, type: Number }) - @ApiResponse({ - status: 200, - description: 'Cost optimization suggestions history retrieved successfully', + @Get(":merchantId/cost-optimization/history") + @ApiOperation({ + summary: "Get cost optimization suggestions history for a merchant", + }) + @ApiParam({ + name: "merchantId", + description: "ID of the merchant to get history for", + required: true, + }) + @ApiQuery({ + name: "status", + description: + "Filter suggestions by status (pending, applied, rejected, archived)", + required: false, + type: String, + }) + @ApiQuery({ + name: "limit", + description: "Maximum number of suggestions to return (default: 10)", + required: false, + type: Number, + }) + @ApiResponse({ + status: 200, + description: "Cost optimization suggestions history retrieved successfully", schema: { - type: 'object', + type: "object", properties: { suggestions: { - type: 'array', + type: "array", items: { - type: 'object', + type: "object", properties: { - id: { type: 'string' }, - type: { type: 'string' }, - description: { type: 'string' }, - estimatedSavingsUSD: { type: 'number' }, - priority: { type: 'number' }, - status: { type: 'string' }, - createdAt: { type: 'string' }, - appliedAt: { type: 'string' }, - metadata: { type: 'object' } - } - } - } - } - } + id: { type: "string" }, + type: { type: "string" }, + description: { type: "string" }, + estimatedSavingsUSD: { type: "number" }, + priority: { type: "number" }, + status: { type: "string" }, + createdAt: { type: "string" }, + appliedAt: { type: "string" }, + metadata: { type: "object" }, + }, + }, + }, + }, + }, }) @HttpCode(HttpStatus.OK) async getCostOptimizationHistory( - @Param('merchantId') merchantId: string, - @Query('status') status?: string, - @Query('limit') limit?: number, + @Param("merchantId") merchantId: string, + @Query("status") status?: string, + @Query("limit") limit?: number, ): Promise<{ suggestions: Array<{ id: string; @@ -181,10 +256,16 @@ export class CostOptimizationController { }>; }> { try { - this.logger.log(`Request to get cost optimization history for merchant ${merchantId}`); + this.logger.log( + `Request to get cost optimization history for merchant ${merchantId}`, + ); // Get suggestions with optional filters - let suggestions = await this.optimizationEngineService.getOptimizationSuggestions(merchantId, status); + let suggestions = + await this.optimizationEngineService.getOptimizationSuggestions( + merchantId, + status, + ); // Apply limit if specified if (limit) { @@ -192,7 +273,7 @@ export class CostOptimizationController { } return { - suggestions: suggestions.map(s => ({ + suggestions: suggestions.map((s) => ({ id: s.id, type: s.type, description: s.description, @@ -202,11 +283,14 @@ export class CostOptimizationController { createdAt: s.createdAt, appliedAt: s.appliedAt, metadata: s.metadata, - })) + })), }; } catch (error) { - this.logger.error(`Error getting cost optimization history for merchant ${merchantId}`, error); + this.logger.error( + `Error getting cost optimization history for merchant ${merchantId}`, + error, + ); throw error; } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/optimization/entities/optimization-suggestion.entity.ts b/apps/api-service/src/optimization/entities/optimization-suggestion.entity.ts index c6fd88c..9a53fad 100644 --- a/apps/api-service/src/optimization/entities/optimization-suggestion.entity.ts +++ b/apps/api-service/src/optimization/entities/optimization-suggestion.entity.ts @@ -1,52 +1,59 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; - -@Entity('optimization_suggestions') +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +@Entity("optimization_suggestions") export class OptimizationSuggestion { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_optimization_merchant_id') + @Column({ type: "varchar", length: 100 }) + @Index("idx_optimization_merchant_id") merchantId: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_optimization_type') + @Column({ type: "varchar", length: 50 }) + @Index("idx_optimization_type") type: string; // 'ChainSwitch', 'TimingAdjustment', 'BatchOptimization', etc. - @Column({ type: 'varchar', length: 500 }) + @Column({ type: "varchar", length: 500 }) description: string; - @Column({ type: 'varchar', length: 100, nullable: true }) - @Index('idx_optimization_category') + @Column({ type: "varchar", length: 100, nullable: true }) + @Index("idx_optimization_category") category?: string; // 'gas', 'performance', 'security', etc. - @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 15, scale: 2, nullable: true }) estimatedSavingsUSD?: number; - @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + @Column({ type: "decimal", precision: 15, scale: 2, nullable: true }) estimatedSavingsGas?: number; - @Column({ type: 'integer', default: 1 }) // 1-5 scale, 5 being highest priority + @Column({ type: "integer", default: 1 }) // 1-5 scale, 5 being highest priority priority: number; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata?: Record; // Additional data about the suggestion - @Column({ type: 'varchar', length: 50, default: 'pending' }) - @Index('idx_optimization_status') + @Column({ type: "varchar", length: 50, default: "pending" }) + @Index("idx_optimization_status") status: string; // 'pending', 'applied', 'rejected', 'archived' - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: "timestamp", nullable: true }) appliedAt?: Date; @CreateDateColumn() - @Index('idx_optimization_created_at') + @Index("idx_optimization_created_at") createdAt: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'timestamp', nullable: true }) - @Index('idx_optimization_expires_at') + @Column({ type: "timestamp", nullable: true }) + @Index("idx_optimization_expires_at") expiresAt?: Date; -} \ No newline at end of file +} diff --git a/apps/api-service/src/optimization/optimization.module.ts b/apps/api-service/src/optimization/optimization.module.ts index 247f091..8b02194 100644 --- a/apps/api-service/src/optimization/optimization.module.ts +++ b/apps/api-service/src/optimization/optimization.module.ts @@ -1,27 +1,24 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { OptimizationSuggestion } from './entities/optimization-suggestion.entity'; -import { OptimizationEngineService } from './services/optimization-engine.service'; -import { DataAnalysisService } from './services/data-analysis.service'; -import { CostOptimizationController } from './controllers/cost-optimization.controller'; -import { Transaction } from '../database/entities/transaction.entity'; -import { Chain } from '../database/entities/chain.entity'; -import { Merchant } from '../database/entities/merchant.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { OptimizationSuggestion } from "./entities/optimization-suggestion.entity"; +import { OptimizationEngineService } from "./services/optimization-engine.service"; +import { DataAnalysisService } from "./services/data-analysis.service"; +import { CostOptimizationController } from "./controllers/cost-optimization.controller"; +import { Transaction } from "../database/entities/transaction.entity"; +import { Chain } from "../database/entities/chain.entity"; +import { Merchant } from "../database/entities/merchant.entity"; @Module({ imports: [ - TypeOrmModule.forFeature([OptimizationSuggestion, Transaction, Chain, Merchant]), + TypeOrmModule.forFeature([ + OptimizationSuggestion, + Transaction, + Chain, + Merchant, + ]), ], - controllers: [ - CostOptimizationController - ], - providers: [ - OptimizationEngineService, - DataAnalysisService, - ], - exports: [ - OptimizationEngineService, - DataAnalysisService, - ] + controllers: [CostOptimizationController], + providers: [OptimizationEngineService, DataAnalysisService], + exports: [OptimizationEngineService, DataAnalysisService], }) -export class OptimizationModule {} \ No newline at end of file +export class OptimizationModule {} diff --git a/apps/api-service/src/optimization/services/data-analysis.service.ts b/apps/api-service/src/optimization/services/data-analysis.service.ts index bee0978..dd7c8f2 100644 --- a/apps/api-service/src/optimization/services/data-analysis.service.ts +++ b/apps/api-service/src/optimization/services/data-analysis.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between } from 'typeorm'; -import { Transaction } from '../../database/entities/transaction.entity'; -import { Chain } from '../../database/entities/chain.entity'; -import { Merchant } from '../../database/entities/merchant.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository, Between } from "typeorm"; +import { Transaction } from "../../database/entities/transaction.entity"; +import { Chain } from "../../database/entities/chain.entity"; +import { Merchant } from "../../database/entities/merchant.entity"; @Injectable() export class DataAnalysisService { @@ -69,7 +69,9 @@ export class DataAnalysisService { }); if (transactions.length === 0) { - throw new Error(`No transactions found for merchant ${merchantId} in the specified period`); + throw new Error( + `No transactions found for merchant ${merchantId} in the specified period`, + ); } // Calculate overall statistics @@ -79,30 +81,36 @@ export class DataAnalysisService { let totalGasPrice = 0; // Group transactions by chain - const chainMap = new Map(); + const chainMap = new Map< + string, + { + transactionCount: number; + totalGasUsed: number; + totalCostUSD: number; + successfulTransactions: number; + } + >(); // Group transactions by hour for time-based analysis - const hourlyMap = new Map(); + const hourlyMap = new Map< + number, + { + transactionCount: number; + totalGasPriceSum: number; + totalCostUSD: number; + } + >(); for (const transaction of transactions) { // Overall stats totalGasUsed += Number(transaction.gasUsed || 0); totalCostUSD += Number(transaction.transactionFee || 0); - + if (transaction.gasPrice) { totalGasPrice += Number(transaction.gasPrice); } - if (transaction.status === 'success') { + if (transaction.status === "success") { successfulTransactions++; } @@ -120,7 +128,7 @@ export class DataAnalysisService { chainData.transactionCount++; chainData.totalGasUsed += Number(transaction.gasUsed || 0); chainData.totalCostUSD += Number(transaction.transactionFee || 0); - if (transaction.status === 'success') { + if (transaction.status === "success") { chainData.successfulTransactions++; } @@ -145,25 +153,34 @@ export class DataAnalysisService { // Get chain names for the breakdown const chainIds = Array.from(chainMap.keys()); const chains = await this.chainRepository.findByIds(chainIds); - const chainNameMap = new Map(chains.map(chain => [chain.id, chain.name])); - - const chainBreakdown = Array.from(chainMap.entries()).map(([chainId, data]) => ({ - chainId, - chainName: chainNameMap.get(chainId) || chainId, - transactionCount: data.transactionCount, - totalGasUsed: data.totalGasUsed, - totalCostUSD: data.totalCostUSD, - avgGasUsed: data.transactionCount > 0 ? data.totalGasUsed / data.transactionCount : 0, - successRate: data.transactionCount > 0 - ? (data.successfulTransactions / data.transactionCount) * 100 - : 0, - })); + const chainNameMap = new Map( + chains.map((chain) => [chain.id, chain.name]), + ); + + const chainBreakdown = Array.from(chainMap.entries()).map( + ([chainId, data]) => ({ + chainId, + chainName: chainNameMap.get(chainId) || chainId, + transactionCount: data.transactionCount, + totalGasUsed: data.totalGasUsed, + totalCostUSD: data.totalCostUSD, + avgGasUsed: + data.transactionCount > 0 + ? data.totalGasUsed / data.transactionCount + : 0, + successRate: + data.transactionCount > 0 + ? (data.successfulTransactions / data.transactionCount) * 100 + : 0, + }), + ); // Calculate gas price volatility - const avgGasPrice = transactions.length > 0 ? totalGasPrice / transactions.length : 0; + const avgGasPrice = + transactions.length > 0 ? totalGasPrice / transactions.length : 0; let minGasPrice = Infinity; let maxGasPrice = -Infinity; - + for (const transaction of transactions) { if (transaction.gasPrice) { const gasPrice = Number(transaction.gasPrice); @@ -180,16 +197,22 @@ export class DataAnalysisService { minGasPrice, maxGasPrice, avgGasPrice, - volatilityIndex: maxGasPrice > 0 ? (maxGasPrice - minGasPrice) / maxGasPrice : 0, + volatilityIndex: + maxGasPrice > 0 ? (maxGasPrice - minGasPrice) / maxGasPrice : 0, }; // Prepare hourly patterns - const timeBasedPatterns = Array.from(hourlyMap.entries()).map(([hour, data]) => ({ - hour, - transactionCount: data.transactionCount, - avgGasPrice: data.transactionCount > 0 ? data.totalGasPriceSum / data.transactionCount : 0, - totalCostUSD: data.totalCostUSD, - })).sort((a, b) => a.hour - b.hour); + const timeBasedPatterns = Array.from(hourlyMap.entries()) + .map(([hour, data]) => ({ + hour, + transactionCount: data.transactionCount, + avgGasPrice: + data.transactionCount > 0 + ? data.totalGasPriceSum / data.transactionCount + : 0, + totalCostUSD: data.totalCostUSD, + })) + .sort((a, b) => a.hour - b.hour); return { merchantId, @@ -197,10 +220,15 @@ export class DataAnalysisService { totalTransactions: transactions.length, successfulTransactions, failedTransactions: transactions.length - successfulTransactions, - successRate: transactions.length > 0 ? (successfulTransactions / transactions.length) * 100 : 0, - avgGasUsed: transactions.length > 0 ? totalGasUsed / transactions.length : 0, + successRate: + transactions.length > 0 + ? (successfulTransactions / transactions.length) * 100 + : 0, + avgGasUsed: + transactions.length > 0 ? totalGasUsed / transactions.length : 0, totalGasUsed, - avgGasPrice: transactions.length > 0 ? totalGasPrice / transactions.length : 0, + avgGasPrice: + transactions.length > 0 ? totalGasPrice / transactions.length : 0, totalCostUSD, }, chainBreakdown, @@ -208,7 +236,10 @@ export class DataAnalysisService { timeBasedPatterns, }; } catch (error) { - this.logger.error(`Failed to analyze transactions for merchant ${merchantId}`, error); + this.logger.error( + `Failed to analyze transactions for merchant ${merchantId}`, + error, + ); throw error; } } @@ -216,9 +247,14 @@ export class DataAnalysisService { /** * Get transaction analysis for a specific time range */ - async getTransactionAnalysis(merchantId: string, daysBack: number = 30): Promise { + async getTransactionAnalysis( + merchantId: string, + daysBack: number = 30, + ): Promise { const endDate = new Date(); - const startDate = new Date(endDate.getTime() - daysBack * 24 * 60 * 60 * 1000); + const startDate = new Date( + endDate.getTime() - daysBack * 24 * 60 * 60 * 1000, + ); return this.analyzeMerchantTransactions(merchantId, startDate, endDate); } @@ -226,22 +262,30 @@ export class DataAnalysisService { /** * Compare chain costs for a merchant */ - async compareChainCosts(merchantId: string, daysBack: number = 30): Promise> { + async compareChainCosts( + merchantId: string, + daysBack: number = 30, + ): Promise< + Array<{ + chainId: string; + chainName: string; + avgGasUsed: number; + avgCostPerTransaction: number; + transactionCount: number; + successRate: number; + }> + > { const analysis = await this.getTransactionAnalysis(merchantId, daysBack); - return analysis.chainBreakdown.map(chain => ({ + return analysis.chainBreakdown.map((chain) => ({ chainId: chain.chainId, chainName: chain.chainName, avgGasUsed: chain.avgGasUsed, - avgCostPerTransaction: chain.transactionCount > 0 ? chain.totalCostUSD / chain.transactionCount : 0, + avgCostPerTransaction: + chain.transactionCount > 0 + ? chain.totalCostUSD / chain.transactionCount + : 0, transactionCount: chain.transactionCount, successRate: chain.successRate, })); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/optimization/services/optimization-engine.service.ts b/apps/api-service/src/optimization/services/optimization-engine.service.ts index 47d3afb..1f4bb35 100644 --- a/apps/api-service/src/optimization/services/optimization-engine.service.ts +++ b/apps/api-service/src/optimization/services/optimization-engine.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { DataAnalysisService } from './data-analysis.service'; -import { OptimizationSuggestion } from '../entities/optimization-suggestion.entity'; -import { v4 as uuidv4 } from 'uuid'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { DataAnalysisService } from "./data-analysis.service"; +import { OptimizationSuggestion } from "../entities/optimization-suggestion.entity"; +import { v4 as uuidv4 } from "uuid"; export interface OptimizationSuggestionDto { type: string; @@ -28,15 +28,23 @@ export class OptimizationEngineService { /** * Generate optimization suggestions for a merchant */ - async generateOptimizationSuggestions(merchantId: string, daysBack: number = 30): Promise { + async generateOptimizationSuggestions( + merchantId: string, + daysBack: number = 30, + ): Promise { try { // Get transaction analysis - const analysis = await this.dataAnalysisService.getTransactionAnalysis(merchantId, daysBack); + const analysis = await this.dataAnalysisService.getTransactionAnalysis( + merchantId, + daysBack, + ); const suggestions: OptimizationSuggestionDto[] = []; // 1. Chain Switch Recommendations - suggestions.push(...await this.generateChainSwitchSuggestions(analysis)); + suggestions.push( + ...(await this.generateChainSwitchSuggestions(analysis)), + ); // 2. Timing Adjustment Recommendations suggestions.push(...this.generateTimingAdjustmentSuggestions(analysis)); @@ -45,14 +53,21 @@ export class OptimizationEngineService { suggestions.push(...this.generateBatchOptimizationSuggestions(analysis)); // 4. Failed Transaction Reduction Recommendations - suggestions.push(...this.generateFailedTransactionReductionSuggestions(analysis)); + suggestions.push( + ...this.generateFailedTransactionReductionSuggestions(analysis), + ); // 5. Gas Price Optimization Recommendations - suggestions.push(...this.generateGasPriceOptimizationSuggestions(analysis)); + suggestions.push( + ...this.generateGasPriceOptimizationSuggestions(analysis), + ); return suggestions; } catch (error) { - this.logger.error(`Failed to generate optimization suggestions for merchant ${merchantId}`, error); + this.logger.error( + `Failed to generate optimization suggestions for merchant ${merchantId}`, + error, + ); throw error; } } @@ -60,38 +75,52 @@ export class OptimizationEngineService { /** * Generate chain switch suggestions based on cost comparison */ - private async generateChainSwitchSuggestions(analysis: any): Promise { + private async generateChainSwitchSuggestions( + analysis: any, + ): Promise { const suggestions: OptimizationSuggestionDto[] = []; - + // Find the most expensive chain and suggest switching to a cheaper alternative const expensiveChains = analysis.chainBreakdown .filter((chain: any) => chain.avgCostPerTransaction > 0) - .sort((a: any, b: any) => b.avgCostPerTransaction - a.avgCostPerTransaction); + .sort( + (a: any, b: any) => b.avgCostPerTransaction - a.avgCostPerTransaction, + ); if (expensiveChains.length > 1) { const mostExpensive = expensiveChains[0]; const cheapest = expensiveChains[expensiveChains.length - 1]; - if (mostExpensive.avgCostPerTransaction > cheapest.avgCostPerTransaction * 1.5) { + if ( + mostExpensive.avgCostPerTransaction > + cheapest.avgCostPerTransaction * 1.5 + ) { // Calculate potential savings const transactionCount = mostExpensive.transactionCount; - const costDifference = mostExpensive.avgCostPerTransaction - cheapest.avgCostPerTransaction; + const costDifference = + mostExpensive.avgCostPerTransaction - cheapest.avgCostPerTransaction; const estimatedSavings = transactionCount * costDifference; suggestions.push({ - type: 'ChainSwitch', + type: "ChainSwitch", description: `Switch ${Math.round((transactionCount / analysis.transactionStats.totalTransactions) * 100)}% of transfers from ${mostExpensive.chainName} to ${cheapest.chainName} to reduce gas costs`, estimatedSavingsUSD: parseFloat(estimatedSavings.toFixed(2)), priority: 4, // High priority - category: 'gas', + category: "gas", metadata: { fromChain: mostExpensive.chainId, toChain: cheapest.chainId, fromChainCost: mostExpensive.avgCostPerTransaction, toChainCost: cheapest.avgCostPerTransaction, transactionCount: transactionCount, - percentageOfTotal: parseFloat(((transactionCount / analysis.transactionStats.totalTransactions) * 100).toFixed(2)) - } + percentageOfTotal: parseFloat( + ( + (transactionCount / + analysis.transactionStats.totalTransactions) * + 100 + ).toFixed(2), + ), + }, }); } } @@ -102,36 +131,45 @@ export class OptimizationEngineService { /** * Generate timing adjustment suggestions based on gas price patterns */ - private generateTimingAdjustmentSuggestions(analysis: any): OptimizationSuggestionDto[] { + private generateTimingAdjustmentSuggestions( + analysis: any, + ): OptimizationSuggestionDto[] { const suggestions: OptimizationSuggestionDto[] = []; // Find hours with lowest gas prices - const sortedHours = [...analysis.timeBasedPatterns].sort((a, b) => a.avgGasPrice - b.avgGasPrice); + const sortedHours = [...analysis.timeBasedPatterns].sort( + (a, b) => a.avgGasPrice - b.avgGasPrice, + ); const lowestCostHour = sortedHours[0]; const highestCostHour = sortedHours[sortedHours.length - 1]; - if (highestCostHour && lowestCostHour && highestCostHour.avgGasPrice > lowestCostHour.avgGasPrice * 1.5) { + if ( + highestCostHour && + lowestCostHour && + highestCostHour.avgGasPrice > lowestCostHour.avgGasPrice * 1.5 + ) { // Calculate potential savings if transactions were moved to low-cost hours const highCostTransactions = analysis.timeBasedPatterns .filter((hour: any) => hour.hour === highestCostHour.hour) .reduce((sum: number, hour: any) => sum + hour.transactionCount, 0); - const costDifference = highestCostHour.avgGasPrice - lowestCostHour.avgGasPrice; + const costDifference = + highestCostHour.avgGasPrice - lowestCostHour.avgGasPrice; const estimatedSavings = highCostTransactions * costDifference; suggestions.push({ - type: 'TimingAdjustment', + type: "TimingAdjustment", description: `Schedule contract interactions during low gas periods (UTC ${lowestCostHour.hour}:00–${lowestCostHour.hour + 1}:00)`, estimatedSavingsUSD: parseFloat(estimatedSavings.toFixed(2)), priority: 3, // Medium-high priority - category: 'gas', + category: "gas", metadata: { optimalHour: lowestCostHour.hour, worstHour: highestCostHour.hour, avgGasPriceLow: lowestCostHour.avgGasPrice, avgGasPriceHigh: highestCostHour.avgGasPrice, - highCostTransactionCount: highCostTransactions - } + highCostTransactionCount: highCostTransactions, + }, }); } @@ -141,7 +179,9 @@ export class OptimizationEngineService { /** * Generate batch optimization suggestions */ - private generateBatchOptimizationSuggestions(analysis: any): OptimizationSuggestionDto[] { + private generateBatchOptimizationSuggestions( + analysis: any, + ): OptimizationSuggestionDto[] { const suggestions: OptimizationSuggestionDto[] = []; // Look for opportunities to batch multiple transactions @@ -151,16 +191,16 @@ export class OptimizationEngineService { const estimatedSavings = analysis.transactionStats.totalCostUSD * 0.2; // 20% savings estimate suggestions.push({ - type: 'BatchOptimization', + type: "BatchOptimization", description: `Consider batching multiple transactions to reduce overall gas costs`, estimatedSavingsUSD: parseFloat(estimatedSavings.toFixed(2)), priority: 2, // Medium priority - category: 'gas', + category: "gas", metadata: { totalTransactions: analysis.transactionStats.totalTransactions, - transactionDensity: 'high', - estimatedSavingsPercentage: 20 - } + transactionDensity: "high", + estimatedSavingsPercentage: 20, + }, }); } @@ -170,31 +210,38 @@ export class OptimizationEngineService { /** * Generate failed transaction reduction suggestions */ - private generateFailedTransactionReductionSuggestions(analysis: any): OptimizationSuggestionDto[] { + private generateFailedTransactionReductionSuggestions( + analysis: any, + ): OptimizationSuggestionDto[] { const suggestions: OptimizationSuggestionDto[] = []; // Check for high failure rate if (analysis.transactionStats.failedTransactions > 0) { - const failureRate = analysis.transactionStats.failedTransactions / analysis.transactionStats.totalTransactions; - - if (failureRate > 0.05) { // More than 5% failure rate + const failureRate = + analysis.transactionStats.failedTransactions / + analysis.transactionStats.totalTransactions; + + if (failureRate > 0.05) { + // More than 5% failure rate // Estimate cost of failed transactions const avgGasUsed = analysis.transactionStats.avgGasUsed; - const failedGasWasted = analysis.transactionStats.failedTransactions * avgGasUsed; + const failedGasWasted = + analysis.transactionStats.failedTransactions * avgGasUsed; const estimatedSavings = failedGasWasted * 0.00000001; // Convert gas to USD approximation suggestions.push({ - type: 'FailedTransactionReduction', + type: "FailedTransactionReduction", description: `Reduce failed transactions which currently account for ${(failureRate * 100).toFixed(2)}% of all transactions`, estimatedSavingsUSD: parseFloat(estimatedSavings.toFixed(2)), priority: 5, // Highest priority - category: 'gas', + category: "gas", metadata: { failureRate: failureRate, - failedTransactionCount: analysis.transactionStats.failedTransactions, + failedTransactionCount: + analysis.transactionStats.failedTransactions, totalTransactionCount: analysis.transactionStats.totalTransactions, - estimatedGasWasted: failedGasWasted - } + estimatedGasWasted: failedGasWasted, + }, }); } } @@ -205,27 +252,30 @@ export class OptimizationEngineService { /** * Generate gas price optimization suggestions */ - private generateGasPriceOptimizationSuggestions(analysis: any): OptimizationSuggestionDto[] { + private generateGasPriceOptimizationSuggestions( + analysis: any, + ): OptimizationSuggestionDto[] { const suggestions: OptimizationSuggestionDto[] = []; // Check for high gas price volatility - if (analysis.gasPriceVolatility.volatilityIndex > 0.5) { // High volatility + if (analysis.gasPriceVolatility.volatilityIndex > 0.5) { + // High volatility const avgGasPrice = analysis.gasPriceVolatility.avgGasPrice; const minGasPrice = analysis.gasPriceVolatility.minGasPrice; const potentialSavings = (avgGasPrice - minGasPrice) * 0.1; // Estimate 10% of transactions could save suggestions.push({ - type: 'GasPriceOptimization', + type: "GasPriceOptimization", description: `Monitor gas prices more closely and submit transactions during low price periods`, estimatedSavingsUSD: parseFloat(potentialSavings.toFixed(2)), priority: 3, // Medium priority - category: 'gas', + category: "gas", metadata: { volatilityIndex: analysis.gasPriceVolatility.volatilityIndex, avgGasPrice: avgGasPrice, minGasPrice: minGasPrice, - maxGasPrice: analysis.gasPriceVolatility.maxGasPrice - } + maxGasPrice: analysis.gasPriceVolatility.maxGasPrice, + }, }); } @@ -235,7 +285,10 @@ export class OptimizationEngineService { /** * Save optimization suggestions to the database */ - async saveOptimizationSuggestions(merchantId: string, suggestions: OptimizationSuggestionDto[]): Promise { + async saveOptimizationSuggestions( + merchantId: string, + suggestions: OptimizationSuggestionDto[], + ): Promise { const savedSuggestionIds: string[] = []; for (const suggestion of suggestions) { @@ -249,14 +302,15 @@ export class OptimizationEngineService { newSuggestion.estimatedSavingsGas = suggestion.estimatedSavingsGas; newSuggestion.priority = suggestion.priority; newSuggestion.metadata = suggestion.metadata; - newSuggestion.status = 'pending'; + newSuggestion.status = "pending"; // Set expiration date to 30 days from now const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 30); newSuggestion.expiresAt = expiresAt; - const savedSuggestion = await this.optimizationSuggestionRepository.save(newSuggestion); + const savedSuggestion = + await this.optimizationSuggestionRepository.save(newSuggestion); savedSuggestionIds.push(savedSuggestion.id); } @@ -266,16 +320,23 @@ export class OptimizationEngineService { /** * Get all optimization suggestions for a merchant */ - async getOptimizationSuggestions(merchantId: string, status?: string): Promise { - const query = this.optimizationSuggestionRepository.createQueryBuilder('suggestion') - .where('suggestion.merchantId = :merchantId', { merchantId }); + async getOptimizationSuggestions( + merchantId: string, + status?: string, + ): Promise { + const query = this.optimizationSuggestionRepository + .createQueryBuilder("suggestion") + .where("suggestion.merchantId = :merchantId", { merchantId }); if (status) { - query.andWhere('suggestion.status = :status', { status }); + query.andWhere("suggestion.status = :status", { status }); } // Order by priority (descending) and then by creation date (descending) - return query.orderBy('suggestion.priority', 'DESC').addOrderBy('suggestion.createdAt', 'DESC').getMany(); + return query + .orderBy("suggestion.priority", "DESC") + .addOrderBy("suggestion.createdAt", "DESC") + .getMany(); } /** @@ -283,7 +344,7 @@ export class OptimizationEngineService { */ async markSuggestionAsApplied(suggestionId: string): Promise { await this.optimizationSuggestionRepository.update(suggestionId, { - status: 'applied', + status: "applied", appliedAt: new Date(), }); } @@ -298,13 +359,17 @@ export class OptimizationEngineService { appliedSuggestions: number; }> { const allSuggestions = await this.getOptimizationSuggestions(merchantId); - + const totalPotentialSavingsUSD = allSuggestions - .filter(s => s.status === 'pending') + .filter((s) => s.status === "pending") .reduce((sum, s) => sum + (s.estimatedSavingsUSD || 0), 0); - - const highPrioritySuggestions = allSuggestions.filter(s => s.priority >= 4 && s.status === 'pending').length; - const appliedSuggestions = allSuggestions.filter(s => s.status === 'applied').length; + + const highPrioritySuggestions = allSuggestions.filter( + (s) => s.priority >= 4 && s.status === "pending", + ).length; + const appliedSuggestions = allSuggestions.filter( + (s) => s.status === "applied", + ).length; return { totalPotentialSavingsUSD: parseFloat(totalPotentialSavingsUSD.toFixed(2)), @@ -313,4 +378,4 @@ export class OptimizationEngineService { appliedSuggestions, }; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/performance-monitoring/__tests__/monitoring-hooks.service.spec.ts b/apps/api-service/src/performance-monitoring/__tests__/monitoring-hooks.service.spec.ts index 1a3d8af..50b5761 100644 --- a/apps/api-service/src/performance-monitoring/__tests__/monitoring-hooks.service.spec.ts +++ b/apps/api-service/src/performance-monitoring/__tests__/monitoring-hooks.service.spec.ts @@ -1,19 +1,19 @@ -import { MonitoringHooksService } from '../services/monitoring-hooks.service'; +import { MonitoringHooksService } from "../services/monitoring-hooks.service"; -describe('MonitoringHooksService', () => { +describe("MonitoringHooksService", () => { let service: MonitoringHooksService; beforeEach(() => { service = new MonitoringHooksService(); }); - it('increments counters using label-insensitive ordering', () => { - service.incrementCounter('http_requests_total', 1, { + it("increments counters using label-insensitive ordering", () => { + service.incrementCounter("http_requests_total", 1, { statusCode: 200, - method: 'GET', + method: "GET", }); - service.incrementCounter('http_requests_total', 2, { - method: 'GET', + service.incrementCounter("http_requests_total", 2, { + method: "GET", statusCode: 200, }); @@ -21,35 +21,35 @@ describe('MonitoringHooksService', () => { expect(snapshot.counters).toEqual([ { - name: 'http_requests_total', - labels: { method: 'GET', statusCode: '200' }, + name: "http_requests_total", + labels: { method: "GET", statusCode: "200" }, value: 3, }, ]); }); - it('tracks gauges and histogram summaries', () => { - service.setGauge('http_requests_in_flight', 2, { method: 'POST' }); - service.observeHistogram('http_request_duration_ms', 100, { - endpoint: '/api/scanner', + it("tracks gauges and histogram summaries", () => { + service.setGauge("http_requests_in_flight", 2, { method: "POST" }); + service.observeHistogram("http_request_duration_ms", 100, { + endpoint: "/api/scanner", }); - service.observeHistogram('http_request_duration_ms', 300, { - endpoint: '/api/scanner', + service.observeHistogram("http_request_duration_ms", 300, { + endpoint: "/api/scanner", }); const snapshot = service.getSnapshot(); expect(snapshot.gauges).toEqual([ { - name: 'http_requests_in_flight', - labels: { method: 'POST' }, + name: "http_requests_in_flight", + labels: { method: "POST" }, value: 2, }, ]); expect(snapshot.histograms).toEqual([ { - name: 'http_request_duration_ms', - labels: { endpoint: '/api/scanner' }, + name: "http_request_duration_ms", + labels: { endpoint: "/api/scanner" }, count: 2, sum: 400, min: 100, diff --git a/apps/api-service/src/performance-monitoring/controllers/metrics.controller.ts b/apps/api-service/src/performance-monitoring/controllers/metrics.controller.ts index 341236d..87ce628 100644 --- a/apps/api-service/src/performance-monitoring/controllers/metrics.controller.ts +++ b/apps/api-service/src/performance-monitoring/controllers/metrics.controller.ts @@ -1,14 +1,16 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { MonitoringHooksService } from '../services/monitoring-hooks.service'; +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { MonitoringHooksService } from "../services/monitoring-hooks.service"; -@ApiTags('Monitoring') -@Controller('metrics') +@ApiTags("Monitoring") +@Controller("metrics") export class MetricsController { - constructor(private readonly monitoringHooksService: MonitoringHooksService) {} + constructor( + private readonly monitoringHooksService: MonitoringHooksService, + ) {} @Get() - @ApiOperation({ summary: 'Expose in-memory monitoring metrics snapshot' }) + @ApiOperation({ summary: "Expose in-memory monitoring metrics snapshot" }) getMetrics() { return this.monitoringHooksService.getSnapshot(); } diff --git a/apps/api-service/src/performance-monitoring/controllers/performance.controller.ts b/apps/api-service/src/performance-monitoring/controllers/performance.controller.ts index 366ffa9..91ba1cf 100644 --- a/apps/api-service/src/performance-monitoring/controllers/performance.controller.ts +++ b/apps/api-service/src/performance-monitoring/controllers/performance.controller.ts @@ -1,20 +1,34 @@ -import { Controller, Get, Post, Query, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { PerformanceMetricService, MetricRecord } from '../services/performance-metric.service'; -import { MetricAggregationWindow } from '../entities/api-performance-metric.entity'; +import { + Controller, + Get, + Post, + Query, + Param, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { + PerformanceMetricService, + MetricRecord, +} from "../services/performance-metric.service"; +import { MetricAggregationWindow } from "../entities/api-performance-metric.entity"; -@ApiTags('Performance Monitoring') -@Controller('api/performance') +@ApiTags("Performance Monitoring") +@Controller("api/performance") export class PerformanceController { - constructor(private readonly performanceMetricService: PerformanceMetricService) {} + constructor( + private readonly performanceMetricService: PerformanceMetricService, + ) {} - @Get('health') + @Get("health") // eslint-disable-next-line @typescript-eslint/no-unused-vars health() { - return { status: 'ok', service: 'performance-monitoring' }; + return { status: "ok", service: "performance-monitoring" }; } - @Post('metrics') + @Post("metrics") @HttpCode(HttpStatus.CREATED) // eslint-disable-next-line @typescript-eslint/no-unused-vars async recordMetric(@Body() metric: MetricRecord) { @@ -22,17 +36,17 @@ export class PerformanceController { return { success: true, id: recorded.id }; } - @Get('realtime') + @Get("realtime") // eslint-disable-next-line @typescript-eslint/no-unused-vars async getRealtimeSummary() { return this.performanceMetricService.getRealtimeSummary(); } - @Get('recent') + @Get("recent") // eslint-disable-next-line @typescript-eslint/no-unused-vars async getRecentMetrics( - @Query('limit') limit?: number, - @Query('endpoint') endpoint?: string, + @Query("limit") limit?: number, + @Query("endpoint") endpoint?: string, ) { return this.performanceMetricService.getRecentMetrics( limit || 100, @@ -40,19 +54,19 @@ export class PerformanceController { ); } - @Get('history/:endpoint') + @Get("history/:endpoint") // eslint-disable-next-line @typescript-eslint/no-unused-vars async getHistory( - @Param('endpoint') endpoint: string, - @Query('window') window?: MetricAggregationWindow, - @Query('startTime') startTime?: string, - @Query('endTime') endTime?: string, + @Param("endpoint") endpoint: string, + @Query("window") window?: MetricAggregationWindow, + @Query("startTime") startTime?: string, + @Query("endTime") endTime?: string, ) { const windowEnum = window || MetricAggregationWindow.HOUR; - + let start: Date; let end: Date; - + if (startTime && endTime) { start = new Date(startTime); end = new Date(endTime); @@ -71,28 +85,31 @@ export class PerformanceController { ); } - @Get('aggregate/:endpoint') + @Get("aggregate/:endpoint") // eslint-disable-next-line @typescript-eslint/no-unused-vars async getAggregate( - @Param('endpoint') endpoint: string, - @Query('window') window?: MetricAggregationWindow, + @Param("endpoint") endpoint: string, + @Query("window") window?: MetricAggregationWindow, ) { const windowEnum = window || MetricAggregationWindow.HOUR; - return this.performanceMetricService.getLatestAggregate(endpoint, windowEnum); + return this.performanceMetricService.getLatestAggregate( + endpoint, + windowEnum, + ); } - @Post('aggregate/:endpoint/:method') + @Post("aggregate/:endpoint/:method") @HttpCode(HttpStatus.CREATED) // eslint-disable-next-line @typescript-eslint/no-unused-vars async triggerAggregation( - @Param('endpoint') endpoint: string, - @Param('method') method: string, - @Query('window') window?: MetricAggregationWindow, + @Param("endpoint") endpoint: string, + @Param("method") method: string, + @Query("window") window?: MetricAggregationWindow, ) { const windowEnum = window || MetricAggregationWindow.HOUR; const endTime = new Date(); const startTime = new Date(); - + // Calculate start time based on window switch (windowEnum) { case MetricAggregationWindow.MINUTE: diff --git a/apps/api-service/src/performance-monitoring/entities/api-performance-metric.entity.ts b/apps/api-service/src/performance-monitoring/entities/api-performance-metric.entity.ts index 832a3b4..5e74a83 100644 --- a/apps/api-service/src/performance-monitoring/entities/api-performance-metric.entity.ts +++ b/apps/api-service/src/performance-monitoring/entities/api-performance-metric.entity.ts @@ -1,99 +1,105 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, +} from "typeorm"; export enum MetricAggregationWindow { - MINUTE = 'minute', - HOUR = 'hour', - DAY = 'day', + MINUTE = "minute", + HOUR = "hour", + DAY = "day", } -@Entity('api_performance_metrics') +@Entity("api_performance_metrics") export class ApiPerformanceMetric { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) endpoint: string; - @Column({ type: 'varchar', length: 10 }) + @Column({ type: "varchar", length: 10 }) method: string; - @Column({ type: 'varchar', length: 500, nullable: true }) + @Column({ type: "varchar", length: 500, nullable: true }) path: string; - @Column({ type: 'integer' }) + @Column({ type: "integer" }) statusCode: number; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) responseTime: number; // in milliseconds - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) timestamp: Date; @CreateDateColumn() createdAt: Date; - @Column({ type: 'varchar', length: 100, nullable: true }) + @Column({ type: "varchar", length: 100, nullable: true }) requestId: string; - @Column({ type: 'varchar', length: 50, nullable: true }) + @Column({ type: "varchar", length: 50, nullable: true }) ip: string; - @Column({ type: 'text', nullable: true }) + @Column({ type: "text", nullable: true }) userAgent: string; } // Aggregated metrics for historical analysis -@Entity('api_performance_aggregates') +@Entity("api_performance_aggregates") export class ApiPerformanceAggregate { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 255 }) + @Column({ type: "varchar", length: 255 }) endpoint: string; - @Column({ type: 'varchar', length: 20 }) + @Column({ type: "varchar", length: 20 }) method: string; - @Column({ type: 'varchar', length: 20 }) + @Column({ type: "varchar", length: 20 }) aggregationWindow: MetricAggregationWindow; - @Column({ type: 'timestamp' }) + @Column({ type: "timestamp" }) timestamp: Date; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) totalRequests: number; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) successfulRequests: number; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) failedRequests: number; - @Column({ type: 'decimal', precision: 10, scale: 2 }) + @Column({ type: "decimal", precision: 10, scale: 2 }) successRate: number; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) minResponseTime: number; - @Column({ type: 'bigint' }) + @Column({ type: "bigint" }) maxResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) avgResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) p50ResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) p90ResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) p95ResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) p99ResponseTime: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) + @Column({ type: "decimal", precision: 15, scale: 2 }) stdDevResponseTime: number; @CreateDateColumn() diff --git a/apps/api-service/src/performance-monitoring/index.ts b/apps/api-service/src/performance-monitoring/index.ts index 00126b0..21530e5 100644 --- a/apps/api-service/src/performance-monitoring/index.ts +++ b/apps/api-service/src/performance-monitoring/index.ts @@ -1,5 +1,5 @@ -export * from './performance-monitoring.module'; -export * from './services/performance-metric.service'; -export * from './services/monitoring-hooks.service'; -export * from './middleware/performance-logging.middleware'; -export * from './entities/api-performance-metric.entity'; +export * from "./performance-monitoring.module"; +export * from "./services/performance-metric.service"; +export * from "./services/monitoring-hooks.service"; +export * from "./middleware/performance-logging.middleware"; +export * from "./entities/api-performance-metric.entity"; diff --git a/apps/api-service/src/performance-monitoring/middleware/performance-logging.middleware.ts b/apps/api-service/src/performance-monitoring/middleware/performance-logging.middleware.ts index 480879b..17ab608 100644 --- a/apps/api-service/src/performance-monitoring/middleware/performance-logging.middleware.ts +++ b/apps/api-service/src/performance-monitoring/middleware/performance-logging.middleware.ts @@ -1,7 +1,7 @@ -import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { MonitoringHooksService } from '../services/monitoring-hooks.service'; -import { PerformanceMetricService } from '../services/performance-metric.service'; +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; +import { MonitoringHooksService } from "../services/monitoring-hooks.service"; +import { PerformanceMetricService } from "../services/performance-metric.service"; export interface ApiRequest extends Request { requestId?: string; @@ -18,33 +18,38 @@ export class PerformanceLoggingMiddleware implements NestMiddleware { ) {} async use(req: ApiRequest, res: Response, next: NextFunction) { - const requestId = req.headers['x-request-id'] as string || this.generateRequestId(); + const requestId = + (req.headers["x-request-id"] as string) || this.generateRequestId(); const startTime = Date.now(); - + req.requestId = requestId; req.startTime = startTime; - this.monitoringHooksService.adjustGauge('http_requests_in_flight', 1, { + this.monitoringHooksService.adjustGauge("http_requests_in_flight", 1, { method: req.method, }); - res.on('finish', () => { + res.on("finish", () => { const duration = Date.now() - startTime; const statusCode = res.statusCode; - this.monitoringHooksService.incrementCounter('http_requests_total', 1, { + this.monitoringHooksService.incrementCounter("http_requests_total", 1, { method: req.method, endpoint: this.categorizeEndpoint(req.path), statusCode, }); - this.monitoringHooksService.adjustGauge('http_requests_in_flight', -1, { + this.monitoringHooksService.adjustGauge("http_requests_in_flight", -1, { method: req.method, }); - this.monitoringHooksService.observeHistogram('http_request_duration_ms', duration, { - method: req.method, - endpoint: this.categorizeEndpoint(req.path), - statusCode, - }); + this.monitoringHooksService.observeHistogram( + "http_request_duration_ms", + duration, + { + method: req.method, + endpoint: this.categorizeEndpoint(req.path), + statusCode, + }, + ); this.logPerformance({ method: req.method, @@ -53,8 +58,8 @@ export class PerformanceLoggingMiddleware implements NestMiddleware { duration, requestId, ip: req.ip || req.connection?.remoteAddress, - userAgent: req.headers['user-agent'], - }).catch(err => { + userAgent: req.headers["user-agent"], + }).catch((err) => { this.logger.error(`Failed to log performance metric: ${err.message}`); }); }); @@ -77,7 +82,7 @@ export class PerformanceLoggingMiddleware implements NestMiddleware { }): Promise { // Determine endpoint category const endpoint = this.categorizeEndpoint(data.path); - + await this.performanceMetricService.recordMetric({ endpoint, method: data.method, @@ -93,8 +98,8 @@ export class PerformanceLoggingMiddleware implements NestMiddleware { private categorizeEndpoint(path: string): string { // Categorize API endpoints for better aggregation - if (path.startsWith('/api/')) { - const parts = path.split('/'); + if (path.startsWith("/api/")) { + const parts = path.split("/"); if (parts.length >= 3) { return `/api/${parts[2]}`; // /api/analysis, /api/health, etc. } diff --git a/apps/api-service/src/performance-monitoring/performance-monitoring.module.ts b/apps/api-service/src/performance-monitoring/performance-monitoring.module.ts index b806bfd..19c980a 100644 --- a/apps/api-service/src/performance-monitoring/performance-monitoring.module.ts +++ b/apps/api-service/src/performance-monitoring/performance-monitoring.module.ts @@ -1,11 +1,19 @@ -import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ApiPerformanceMetric, ApiPerformanceAggregate } from './entities/api-performance-metric.entity'; -import { MetricsController } from './controllers/metrics.controller'; -import { PerformanceLoggingMiddleware } from './middleware/performance-logging.middleware'; -import { MonitoringHooksService } from './services/monitoring-hooks.service'; -import { PerformanceMetricService } from './services/performance-metric.service'; -import { PerformanceController } from './controllers/performance.controller'; +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { + ApiPerformanceMetric, + ApiPerformanceAggregate, +} from "./entities/api-performance-metric.entity"; +import { MetricsController } from "./controllers/metrics.controller"; +import { PerformanceLoggingMiddleware } from "./middleware/performance-logging.middleware"; +import { MonitoringHooksService } from "./services/monitoring-hooks.service"; +import { PerformanceMetricService } from "./services/performance-metric.service"; +import { PerformanceController } from "./controllers/performance.controller"; @Module({ imports: [ @@ -24,9 +32,9 @@ export class PerformanceMonitoringModule implements NestModule { consumer .apply(PerformanceLoggingMiddleware) .exclude( - { path: 'health', method: RequestMethod.ALL }, - { path: 'metrics', method: RequestMethod.ALL }, + { path: "health", method: RequestMethod.ALL }, + { path: "metrics", method: RequestMethod.ALL }, ) - .forRoutes({ path: '*', method: RequestMethod.ALL }); + .forRoutes({ path: "*", method: RequestMethod.ALL }); } } diff --git a/apps/api-service/src/performance-monitoring/repositories/api-performance-metric.repository.ts b/apps/api-service/src/performance-monitoring/repositories/api-performance-metric.repository.ts index e171e05..77fef99 100644 --- a/apps/api-service/src/performance-monitoring/repositories/api-performance-metric.repository.ts +++ b/apps/api-service/src/performance-monitoring/repositories/api-performance-metric.repository.ts @@ -1,5 +1,9 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { ApiPerformanceMetric, ApiPerformanceAggregate, MetricAggregationWindow } from '../entities/api-performance-metric.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { + ApiPerformanceMetric, + ApiPerformanceAggregate, + MetricAggregationWindow, +} from "../entities/api-performance-metric.entity"; @EntityRepository(ApiPerformanceMetric) export class ApiPerformanceMetricRepository extends Repository { @@ -8,27 +12,27 @@ export class ApiPerformanceMetricRepository extends Repository { - const query = this.createQueryBuilder('metric') - .where('metric.timestamp >= :startTime', { startTime }) - .andWhere('metric.timestamp <= :endTime', { endTime }); + const query = this.createQueryBuilder("metric") + .where("metric.timestamp >= :startTime", { startTime }) + .andWhere("metric.timestamp <= :endTime", { endTime }); if (endpoint) { - query.andWhere('metric.endpoint = :endpoint', { endpoint }); + query.andWhere("metric.endpoint = :endpoint", { endpoint }); } - return query.orderBy('metric.timestamp', 'ASC').getMany(); + return query.orderBy("metric.timestamp", "ASC").getMany(); } async findRecentMetrics( limit: number = 100, endpoint?: string, ): Promise { - const query = this.createQueryBuilder('metric') - .orderBy('metric.timestamp', 'DESC') + const query = this.createQueryBuilder("metric") + .orderBy("metric.timestamp", "DESC") .take(limit); if (endpoint) { - query.andWhere('metric.endpoint = :endpoint', { endpoint }); + query.andWhere("metric.endpoint = :endpoint", { endpoint }); } return query.getMany(); @@ -40,30 +44,30 @@ export class ApiPerformanceMetricRepository extends Repository { - const results = await this.createQueryBuilder('metric') - .select('metric.responseTime', 'responseTime') - .where('metric.timestamp >= :startTime', { startTime }) - .andWhere('metric.timestamp <= :endTime', { endTime }) - .andWhere('metric.endpoint = :endpoint', { endpoint }) - .andWhere('metric.method = :method', { method }) - .orderBy('metric.responseTime', 'ASC') + const results = await this.createQueryBuilder("metric") + .select("metric.responseTime", "responseTime") + .where("metric.timestamp >= :startTime", { startTime }) + .andWhere("metric.timestamp <= :endTime", { endTime }) + .andWhere("metric.endpoint = :endpoint", { endpoint }) + .andWhere("metric.method = :method", { method }) + .orderBy("metric.responseTime", "ASC") .getRawMany(); return results.map((r: { responseTime: string }) => Number(r.responseTime)); } async getEndpointStats(startTime: Date, endTime: Date): Promise { - return this.createQueryBuilder('metric') - .select('metric.endpoint', 'endpoint') - .addSelect('metric.method', 'method') - .addSelect('COUNT(*)', 'totalRequests') - .addSelect('AVG(metric.responseTime)', 'avgResponseTime') - .addSelect('MIN(metric.responseTime)', 'minResponseTime') - .addSelect('MAX(metric.responseTime)', 'maxResponseTime') - .where('metric.timestamp >= :startTime', { startTime }) - .andWhere('metric.timestamp <= :endTime', { endTime }) - .groupBy('metric.endpoint') - .addGroupBy('metric.method') + return this.createQueryBuilder("metric") + .select("metric.endpoint", "endpoint") + .addSelect("metric.method", "method") + .addSelect("COUNT(*)", "totalRequests") + .addSelect("AVG(metric.responseTime)", "avgResponseTime") + .addSelect("MIN(metric.responseTime)", "minResponseTime") + .addSelect("MAX(metric.responseTime)", "maxResponseTime") + .where("metric.timestamp >= :startTime", { startTime }) + .andWhere("metric.timestamp <= :endTime", { endTime }) + .groupBy("metric.endpoint") + .addGroupBy("metric.method") .getRawMany(); } @@ -73,7 +77,7 @@ export class ApiPerformanceMetricRepository extends Repository { - return this.createQueryBuilder('aggregate') - .where('aggregate.endpoint = :endpoint', { endpoint }) - .andWhere('aggregate.aggregationWindow = :window', { window }) - .andWhere('aggregate.timestamp >= :startTime', { startTime }) - .andWhere('aggregate.timestamp <= :endTime', { endTime }) - .orderBy('aggregate.timestamp', 'ASC') + return this.createQueryBuilder("aggregate") + .where("aggregate.endpoint = :endpoint", { endpoint }) + .andWhere("aggregate.aggregationWindow = :window", { window }) + .andWhere("aggregate.timestamp >= :startTime", { startTime }) + .andWhere("aggregate.timestamp <= :endTime", { endTime }) + .orderBy("aggregate.timestamp", "ASC") .getMany(); } @@ -101,10 +105,10 @@ export class ApiPerformanceAggregateRepository extends Repository { - return this.createQueryBuilder('aggregate') - .where('aggregate.endpoint = :endpoint', { endpoint }) - .andWhere('aggregate.aggregationWindow = :window', { window }) - .orderBy('aggregate.timestamp', 'DESC') + return this.createQueryBuilder("aggregate") + .where("aggregate.endpoint = :endpoint", { endpoint }) + .andWhere("aggregate.aggregationWindow = :window", { window }) + .orderBy("aggregate.timestamp", "DESC") .take(1) .getOne(); } diff --git a/apps/api-service/src/performance-monitoring/services/monitoring-hooks.service.ts b/apps/api-service/src/performance-monitoring/services/monitoring-hooks.service.ts index c4c1c0d..6633677 100644 --- a/apps/api-service/src/performance-monitoring/services/monitoring-hooks.service.ts +++ b/apps/api-service/src/performance-monitoring/services/monitoring-hooks.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; export interface MetricLabels { [key: string]: string | number | boolean | undefined; @@ -65,7 +65,11 @@ export class MonitoringHooksService { return next; } - observeHistogram(name: string, value: number, labels?: MetricLabels): HistogramMetricSnapshot { + observeHistogram( + name: string, + value: number, + labels?: MetricLabels, + ): HistogramMetricSnapshot { const key = this.buildMetricKey(name, labels); const current = this.histograms.get(key) || { count: 0, @@ -126,7 +130,10 @@ export class MonitoringHooksService { }, {}); } - private parseMetricKey(key: string): { name: string; labels: Record } { + private parseMetricKey(key: string): { + name: string; + labels: Record; + } { return JSON.parse(key) as { name: string; labels: Record }; } diff --git a/apps/api-service/src/performance-monitoring/services/performance-metric.service.ts b/apps/api-service/src/performance-monitoring/services/performance-metric.service.ts index 1ab90b4..54962c2 100644 --- a/apps/api-service/src/performance-monitoring/services/performance-metric.service.ts +++ b/apps/api-service/src/performance-monitoring/services/performance-metric.service.ts @@ -1,7 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ApiPerformanceMetric, ApiPerformanceAggregate, MetricAggregationWindow } from '../entities/api-performance-metric.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { + ApiPerformanceMetric, + ApiPerformanceAggregate, + MetricAggregationWindow, +} from "../entities/api-performance-metric.entity"; export interface MetricRecord { endpoint: string; @@ -31,9 +35,12 @@ export class PerformanceMetricService { return this.metricRepository.save(newMetric); } - async getRecentMetrics(limit: number = 100, endpoint?: string): Promise { + async getRecentMetrics( + limit: number = 100, + endpoint?: string, + ): Promise { return this.metricRepository.find({ - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, take: limit, where: endpoint ? { endpoint } : undefined, }); @@ -52,7 +59,7 @@ export class PerformanceMetricService { } as any, ...(endpoint ? { endpoint } : {}), }, - order: { timestamp: 'ASC' }, + order: { timestamp: "ASC" }, }); } @@ -71,7 +78,7 @@ export class PerformanceMetricService { $lte: endTime, } as any, }, - order: { timestamp: 'ASC' }, + order: { timestamp: "ASC" }, }); } @@ -81,7 +88,7 @@ export class PerformanceMetricService { ): Promise { return this.aggregateRepository.findOne({ where: { endpoint, aggregationWindow: window }, - order: { timestamp: 'DESC' }, + order: { timestamp: "DESC" }, }); } @@ -97,8 +104,9 @@ export class PerformanceMetricService { const upper = Math.ceil(index); const weight = index - lower; - if (upper >= sortedValues.length) return sortedValues[sortedValues.length - 1]; - + if (upper >= sortedValues.length) + return sortedValues[sortedValues.length - 1]; + return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; } @@ -107,8 +115,9 @@ export class PerformanceMetricService { */ calculateStdDev(values: number[], mean: number): number { if (values.length === 0) return 0; - const squaredDiffs = values.map(v => Math.pow(v - mean, 2)); - const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; + const squaredDiffs = values.map((v) => Math.pow(v - mean, 2)); + const avgSquaredDiff = + squaredDiffs.reduce((a, b) => a + b, 0) / values.length; return Math.sqrt(avgSquaredDiff); } @@ -134,23 +143,33 @@ export class PerformanceMetricService { }); if (metrics.length === 0) { - throw new Error(`No metrics found for endpoint ${endpoint} in the specified time range`); + throw new Error( + `No metrics found for endpoint ${endpoint} in the specified time range`, + ); } - const responseTimes = metrics.map(m => Number(m.responseTime)).sort((a, b) => a - b); + const responseTimes = metrics + .map((m) => Number(m.responseTime)) + .sort((a, b) => a - b); const totalRequests = metrics.length; - const successfulRequests = metrics.filter(m => m.statusCode >= 200 && m.statusCode < 400).length; + const successfulRequests = metrics.filter( + (m) => m.statusCode >= 200 && m.statusCode < 400, + ).length; const failedRequests = totalRequests - successfulRequests; const successRate = (successfulRequests / totalRequests) * 100; const minResponseTime = responseTimes[0]; const maxResponseTime = responseTimes[responseTimes.length - 1]; - const avgResponseTime = responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length; + const avgResponseTime = + responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length; const p50ResponseTime = this.calculatePercentile(responseTimes, 50); const p90ResponseTime = this.calculatePercentile(responseTimes, 90); const p95ResponseTime = this.calculatePercentile(responseTimes, 95); const p99ResponseTime = this.calculatePercentile(responseTimes, 99); - const stdDevResponseTime = this.calculateStdDev(responseTimes, avgResponseTime); + const stdDevResponseTime = this.calculateStdDev( + responseTimes, + avgResponseTime, + ); const aggregate = this.aggregateRepository.create({ endpoint, @@ -210,8 +229,10 @@ export class PerformanceMetricService { }; } - const responseTimes = metrics.map(m => Number(m.responseTime)).sort((a, b) => a - b); - const errors = metrics.filter(m => m.statusCode >= 400).length; + const responseTimes = metrics + .map((m) => Number(m.responseTime)) + .sort((a, b) => a - b); + const errors = metrics.filter((m) => m.statusCode >= 400).length; // Group by endpoint const endpointMap = new Map(); @@ -221,19 +242,24 @@ export class PerformanceMetricService { endpointMap.set(metric.endpoint, existing); } - const endpoints = Array.from(endpointMap.entries()).map(([endpoint, endpointMetrics]) => { - const times = endpointMetrics.map(m => Number(m.responseTime)).sort((a, b) => a - b); - return { - endpoint, - requests: endpointMetrics.length, - avgResponseTime: times.reduce((a, b) => a + b, 0) / times.length, - p95ResponseTime: this.calculatePercentile(times, 95), - }; - }); + const endpoints = Array.from(endpointMap.entries()).map( + ([endpoint, endpointMetrics]) => { + const times = endpointMetrics + .map((m) => Number(m.responseTime)) + .sort((a, b) => a - b); + return { + endpoint, + requests: endpointMetrics.length, + avgResponseTime: times.reduce((a, b) => a + b, 0) / times.length, + p95ResponseTime: this.calculatePercentile(times, 95), + }; + }, + ); return { totalRequests: metrics.length, - avgResponseTime: responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length, + avgResponseTime: + responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length, p95ResponseTime: this.calculatePercentile(responseTimes, 95), errorRate: (errors / metrics.length) * 100, endpoints, @@ -249,7 +275,9 @@ export class PerformanceMetricService { // Note: In a real implementation, this would use a proper delete query // For now, we'll return 0 as a placeholder - this.logger.log(`Would delete metrics older than ${cutoffDate.toISOString()}`); + this.logger.log( + `Would delete metrics older than ${cutoffDate.toISOString()}`, + ); return 0; } } diff --git a/apps/api-service/src/rbac/decorators/current-user.decorator.ts b/apps/api-service/src/rbac/decorators/current-user.decorator.ts index f4ee3f0..8ba61a7 100644 --- a/apps/api-service/src/rbac/decorators/current-user.decorator.ts +++ b/apps/api-service/src/rbac/decorators/current-user.decorator.ts @@ -1,5 +1,5 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { User } from '../../database/entities/user.entity'; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { User } from "../../database/entities/user.entity"; /** * Interface for authenticated request @@ -19,7 +19,10 @@ export interface AuthenticatedRequest extends Request { * ``` */ export const CurrentUser = createParamDecorator( - (data: keyof User | undefined, ctx: ExecutionContext): User | Partial => { + ( + data: keyof User | undefined, + ctx: ExecutionContext, + ): User | Partial => { const request = ctx.switchToHttp().getRequest(); const user = request.user; diff --git a/apps/api-service/src/rbac/decorators/index.ts b/apps/api-service/src/rbac/decorators/index.ts index bc8c663..866ee66 100644 --- a/apps/api-service/src/rbac/decorators/index.ts +++ b/apps/api-service/src/rbac/decorators/index.ts @@ -1,2 +1,2 @@ -export * from './roles.decorator'; -export * from './current-user.decorator'; +export * from "./roles.decorator"; +export * from "./current-user.decorator"; diff --git a/apps/api-service/src/rbac/decorators/roles.decorator.ts b/apps/api-service/src/rbac/decorators/roles.decorator.ts index b316646..8abbbd1 100644 --- a/apps/api-service/src/rbac/decorators/roles.decorator.ts +++ b/apps/api-service/src/rbac/decorators/roles.decorator.ts @@ -1,10 +1,10 @@ -import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '../enums/role.enum'; +import { SetMetadata } from "@nestjs/common"; +import { UserRole } from "../enums/role.enum"; /** * Metadata key for roles */ -export const ROLES_KEY = 'roles'; +export const ROLES_KEY = "roles"; /** * Decorator to specify required roles for accessing a route @@ -50,4 +50,5 @@ export const OperatorAndAbove = () => Roles(UserRole.OPERATOR, UserRole.ADMIN); * getReports() { ... } * ``` */ -export const ViewerAndAbove = () => Roles(UserRole.VIEWER, UserRole.OPERATOR, UserRole.ADMIN); +export const ViewerAndAbove = () => + Roles(UserRole.VIEWER, UserRole.OPERATOR, UserRole.ADMIN); diff --git a/apps/api-service/src/rbac/enums/index.ts b/apps/api-service/src/rbac/enums/index.ts index cd9a627..cd974d4 100644 --- a/apps/api-service/src/rbac/enums/index.ts +++ b/apps/api-service/src/rbac/enums/index.ts @@ -1 +1 @@ -export * from './role.enum'; +export * from "./role.enum"; diff --git a/apps/api-service/src/rbac/enums/role.enum.ts b/apps/api-service/src/rbac/enums/role.enum.ts index 9efe67a..b0a7a84 100644 --- a/apps/api-service/src/rbac/enums/role.enum.ts +++ b/apps/api-service/src/rbac/enums/role.enum.ts @@ -1,11 +1,9 @@ - export enum UserRole { - ADMIN = 'admin', - OPERATOR = 'operator', - VIEWER = 'viewer', + ADMIN = "admin", + OPERATOR = "operator", + VIEWER = "viewer", } - export const RoleHierarchy: UserRole[] = [ UserRole.VIEWER, UserRole.OPERATOR, @@ -17,13 +15,16 @@ export const RoleHierarchy: UserRole[] = [ * @param requiredRole - The minimum required role * @returns boolean indicating if user has sufficient permissions */ -export function hasRoleAccess(userRole: UserRole, requiredRole: UserRole): boolean { +export function hasRoleAccess( + userRole: UserRole, + requiredRole: UserRole, +): boolean { const userRoleIndex = RoleHierarchy.indexOf(userRole); const requiredRoleIndex = RoleHierarchy.indexOf(requiredRole); - + if (userRoleIndex === -1 || requiredRoleIndex === -1) { return false; } - + return userRoleIndex >= requiredRoleIndex; } diff --git a/apps/api-service/src/rbac/guards/index.ts b/apps/api-service/src/rbac/guards/index.ts index 18ee200..18c1340 100644 --- a/apps/api-service/src/rbac/guards/index.ts +++ b/apps/api-service/src/rbac/guards/index.ts @@ -1 +1 @@ -export * from './roles.guard'; +export * from "./roles.guard"; diff --git a/apps/api-service/src/rbac/guards/permissions.guard.ts b/apps/api-service/src/rbac/guards/permissions.guard.ts index 7ef7f89..cdf91de 100644 --- a/apps/api-service/src/rbac/guards/permissions.guard.ts +++ b/apps/api-service/src/rbac/guards/permissions.guard.ts @@ -4,40 +4,40 @@ import { ExecutionContext, ForbiddenException, UnauthorizedException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '../enums/role.enum'; -import { AuthenticatedRequest } from '../decorators/current-user.decorator'; +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { SetMetadata } from "@nestjs/common"; +import { UserRole } from "../enums/role.enum"; +import { AuthenticatedRequest } from "../decorators/current-user.decorator"; export enum Permission { // Gas operations - GAS_READ = 'gas:read', - GAS_WRITE = 'gas:write', - GAS_SUBSIDY_APPROVE = 'gas:subsidy:approve', + GAS_READ = "gas:read", + GAS_WRITE = "gas:write", + GAS_SUBSIDY_APPROVE = "gas:subsidy:approve", // Analytics - ANALYTICS_READ = 'analytics:read', - ANALYTICS_EXPORT = 'analytics:export', + ANALYTICS_READ = "analytics:read", + ANALYTICS_EXPORT = "analytics:export", // User management - USER_READ = 'user:read', - USER_WRITE = 'user:write', - USER_DELETE = 'user:delete', - USER_ROLE_ASSIGN = 'user:role:assign', + USER_READ = "user:read", + USER_WRITE = "user:write", + USER_DELETE = "user:delete", + USER_ROLE_ASSIGN = "user:role:assign", // API keys - API_KEY_READ = 'apikey:read', - API_KEY_WRITE = 'apikey:write', - API_KEY_REVOKE = 'apikey:revoke', + API_KEY_READ = "apikey:read", + API_KEY_WRITE = "apikey:write", + API_KEY_REVOKE = "apikey:revoke", // Audit - AUDIT_READ = 'audit:read', + AUDIT_READ = "audit:read", // Admin - SYSTEM_CONFIG = 'system:config', - EMERGENCY_OVERRIDE = 'system:emergency:override', - PAUSE_CONTROL = 'system:pause', + SYSTEM_CONFIG = "system:config", + EMERGENCY_OVERRIDE = "system:emergency:override", + PAUSE_CONTROL = "system:pause", } export const ROLE_PERMISSIONS: Record = { @@ -62,7 +62,7 @@ export const ROLE_PERMISSIONS: Record = { [UserRole.ADMIN]: Object.values(Permission), }; -export const PERMISSIONS_KEY = 'permissions'; +export const PERMISSIONS_KEY = "permissions"; export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); @@ -72,10 +72,10 @@ export class PermissionsGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const required = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const required = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); if (!required || required.length === 0) { return true; @@ -85,11 +85,11 @@ export class PermissionsGuard implements CanActivate { const user = request.user; if (!user) { - throw new UnauthorizedException('Authentication required'); + throw new UnauthorizedException("Authentication required"); } if (!user.isActive) { - throw new ForbiddenException('User account is deactivated'); + throw new ForbiddenException("User account is deactivated"); } const granted = ROLE_PERMISSIONS[user.role] ?? []; @@ -97,7 +97,7 @@ export class PermissionsGuard implements CanActivate { if (missing.length > 0) { throw new ForbiddenException( - `Missing permission(s): ${missing.join(', ')}`, + `Missing permission(s): ${missing.join(", ")}`, ); } diff --git a/apps/api-service/src/rbac/guards/roles.guard.ts b/apps/api-service/src/rbac/guards/roles.guard.ts index 038dcdf..af72f62 100644 --- a/apps/api-service/src/rbac/guards/roles.guard.ts +++ b/apps/api-service/src/rbac/guards/roles.guard.ts @@ -1,8 +1,14 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { UserRole, hasRoleAccess } from '../enums/role.enum'; -import { ROLES_KEY } from '../decorators/roles.decorator'; -import { AuthenticatedRequest } from '../decorators/current-user.decorator'; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { UserRole, hasRoleAccess } from "../enums/role.enum"; +import { ROLES_KEY } from "../decorators/roles.decorator"; +import { AuthenticatedRequest } from "../decorators/current-user.decorator"; /** * Guard to enforce role-based access control @@ -14,10 +20,10 @@ export class RolesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { // Get required roles from the route handler or controller - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); // If no roles are specified, allow access (public endpoint) if (!requiredRoles || requiredRoles.length === 0) { @@ -29,25 +35,27 @@ export class RolesGuard implements CanActivate { // Check if user is authenticated if (!user) { - throw new UnauthorizedException('Authentication required'); + throw new UnauthorizedException("Authentication required"); } // Check if user account is active if (!user.isActive) { - throw new ForbiddenException('User account is deactivated'); + throw new ForbiddenException("User account is deactivated"); } // Check if user account is locked if (user.isLocked()) { - throw new ForbiddenException('User account is temporarily locked'); + throw new ForbiddenException("User account is temporarily locked"); } // Check if user has any of the required roles - const hasRequiredRole = requiredRoles.some((role: UserRole) => hasRoleAccess(user.role, role)); + const hasRequiredRole = requiredRoles.some((role: UserRole) => + hasRoleAccess(user.role, role), + ); if (!hasRequiredRole) { throw new ForbiddenException( - `Access denied. Required role(s): ${requiredRoles.join(', ')}. Your role: ${user.role}`, + `Access denied. Required role(s): ${requiredRoles.join(", ")}. Your role: ${user.role}`, ); } diff --git a/apps/api-service/src/rbac/index.ts b/apps/api-service/src/rbac/index.ts index 0fffcbe..f600283 100644 --- a/apps/api-service/src/rbac/index.ts +++ b/apps/api-service/src/rbac/index.ts @@ -1,5 +1,5 @@ -export * from './rbac.module'; -export * from './enums'; -export * from './decorators'; -export * from './guards'; -export * from './services'; +export * from "./rbac.module"; +export * from "./enums"; +export * from "./decorators"; +export * from "./guards"; +export * from "./services"; diff --git a/apps/api-service/src/rbac/rbac.module.ts b/apps/api-service/src/rbac/rbac.module.ts index e3d8565..46bd523 100644 --- a/apps/api-service/src/rbac/rbac.module.ts +++ b/apps/api-service/src/rbac/rbac.module.ts @@ -1,10 +1,8 @@ -import { Module, Global } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '../database/entities/user.entity'; -import { RbacService } from './services/rbac.service'; -import { RolesGuard } from './guards/roles.guard'; - - +import { Module, Global } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { User } from "../database/entities/user.entity"; +import { RbacService } from "./services/rbac.service"; +import { RolesGuard } from "./guards/roles.guard"; @Global() @Module({ diff --git a/apps/api-service/src/rbac/services/index.ts b/apps/api-service/src/rbac/services/index.ts index 9e9b3fa..6b45738 100644 --- a/apps/api-service/src/rbac/services/index.ts +++ b/apps/api-service/src/rbac/services/index.ts @@ -1 +1 @@ -export * from './rbac.service'; +export * from "./rbac.service"; diff --git a/apps/api-service/src/rbac/services/rbac.service.ts b/apps/api-service/src/rbac/services/rbac.service.ts index a1101e3..38ee498 100644 --- a/apps/api-service/src/rbac/services/rbac.service.ts +++ b/apps/api-service/src/rbac/services/rbac.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { User } from '../../database/entities/user.entity'; -import { UserRole, hasRoleAccess } from '../enums/role.enum'; +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { User } from "../../database/entities/user.entity"; +import { UserRole, hasRoleAccess } from "../enums/role.enum"; /** * DTO for creating a new user @@ -59,7 +64,9 @@ export class RbacService { }); if (existingUser) { - throw new ConflictException(`User with email ${dto.email} already exists`); + throw new ConflictException( + `User with email ${dto.email} already exists`, + ); } const user = this.userRepository.create({ @@ -101,9 +108,9 @@ export class RbacService { */ async findByEmailWithPassword(email: string): Promise { return this.userRepository - .createQueryBuilder('user') - .addSelect('user.passwordHash') - .where('user.email = :email', { email }) + .createQueryBuilder("user") + .addSelect("user.passwordHash") + .where("user.email = :email", { email }) .getOne(); } @@ -125,7 +132,7 @@ export class RbacService { // Prevent changing own role (security measure) if (id === dto.updatedBy) { - throw new BadRequestException('Cannot change your own role'); + throw new BadRequestException("Cannot change your own role"); } user.role = dto.role; @@ -140,7 +147,7 @@ export class RbacService { // Prevent self-deletion if (id === deletedBy) { - throw new BadRequestException('Cannot delete your own account'); + throw new BadRequestException("Cannot delete your own account"); } await this.userRepository.remove(user); @@ -156,20 +163,20 @@ export class RbacService { skip?: number; take?: number; }): Promise<{ users: User[]; total: number }> { - const queryBuilder = this.userRepository.createQueryBuilder('user'); + const queryBuilder = this.userRepository.createQueryBuilder("user"); if (options?.merchantId) { - queryBuilder.andWhere('user.merchantId = :merchantId', { + queryBuilder.andWhere("user.merchantId = :merchantId", { merchantId: options.merchantId, }); } if (options?.role) { - queryBuilder.andWhere('user.role = :role', { role: options.role }); + queryBuilder.andWhere("user.role = :role", { role: options.role }); } if (options?.isActive !== undefined) { - queryBuilder.andWhere('user.isActive = :isActive', { + queryBuilder.andWhere("user.isActive = :isActive", { isActive: options.isActive, }); } @@ -265,19 +272,29 @@ export class RbacService { locked: number; }> { const total = await this.userRepository.count(); - const active = await this.userRepository.count({ where: { isActive: true } }); - const inactive = await this.userRepository.count({ where: { isActive: false } }); + const active = await this.userRepository.count({ + where: { isActive: true }, + }); + const inactive = await this.userRepository.count({ + where: { isActive: false }, + }); const byRole: Record = { - [UserRole.ADMIN]: await this.userRepository.count({ where: { role: UserRole.ADMIN } }), - [UserRole.OPERATOR]: await this.userRepository.count({ where: { role: UserRole.OPERATOR } }), - [UserRole.VIEWER]: await this.userRepository.count({ where: { role: UserRole.VIEWER } }), + [UserRole.ADMIN]: await this.userRepository.count({ + where: { role: UserRole.ADMIN }, + }), + [UserRole.OPERATOR]: await this.userRepository.count({ + where: { role: UserRole.OPERATOR }, + }), + [UserRole.VIEWER]: await this.userRepository.count({ + where: { role: UserRole.VIEWER }, + }), }; // Count locked users (where lockedUntil is in the future) const lockedResult = await this.userRepository - .createQueryBuilder('user') - .where('user.lockedUntil > NOW()') + .createQueryBuilder("user") + .where("user.lockedUntil > NOW()") .getCount(); return { diff --git a/apps/api-service/src/reports/__tests__/report.service.spec.ts b/apps/api-service/src/reports/__tests__/report.service.spec.ts index a598141..c77a380 100644 --- a/apps/api-service/src/reports/__tests__/report.service.spec.ts +++ b/apps/api-service/src/reports/__tests__/report.service.spec.ts @@ -1,15 +1,15 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ReportService } from '../services/report.service'; -import { DataAggregationService } from '../services/data-aggregation.service'; -import { ReportGenerationService } from '../services/report-generation.service'; -import { EmailNotificationService } from '../services/email-notification.service'; -import { Report } from '../entities/report.entity'; -import { Merchant } from '../../database/entities/merchant.entity'; -import { v4 as uuidv4 } from 'uuid'; - -describe('ReportService', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { ReportService } from "../services/report.service"; +import { DataAggregationService } from "../services/data-aggregation.service"; +import { ReportGenerationService } from "../services/report-generation.service"; +import { EmailNotificationService } from "../services/email-notification.service"; +import { Report } from "../entities/report.entity"; +import { Merchant } from "../../database/entities/merchant.entity"; +import { v4 as uuidv4 } from "uuid"; + +describe("ReportService", () => { let service: ReportService; let reportRepository: Repository; let merchantRepository: Repository; @@ -56,86 +56,98 @@ describe('ReportService', () => { }).compile(); service = module.get(ReportService); - reportRepository = module.get>(getRepositoryToken(Report)); - merchantRepository = module.get>(getRepositoryToken(Merchant)); - dataAggregationService = module.get(DataAggregationService); - reportGenerationService = module.get(ReportGenerationService); - emailNotificationService = module.get(EmailNotificationService); + reportRepository = module.get>( + getRepositoryToken(Report), + ); + merchantRepository = module.get>( + getRepositoryToken(Merchant), + ); + dataAggregationService = module.get( + DataAggregationService, + ); + reportGenerationService = module.get( + ReportGenerationService, + ); + emailNotificationService = module.get( + EmailNotificationService, + ); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('generateAdhocReport', () => { - it('should create a new ad-hoc report', async () => { - const merchantId = 'test-merchant-id'; - const period: 'weekly' | 'monthly' = 'weekly'; - + describe("generateAdhocReport", () => { + it("should create a new ad-hoc report", async () => { + const merchantId = "test-merchant-id"; + const period: "weekly" | "monthly" = "weekly"; + // Mock merchant repository to return a merchant - jest.spyOn(merchantRepository, 'findOne').mockResolvedValue({ + jest.spyOn(merchantRepository, "findOne").mockResolvedValue({ id: merchantId, - name: 'Test Merchant', - email: 'test@example.com', + name: "Test Merchant", + email: "test@example.com", } as Merchant); - + // Mock report repository save method const savedReport = { id: uuidv4(), - type: 'adhoc', - period: 'weekly', + type: "adhoc", + period: "weekly", merchantId, - status: 'pending', + status: "pending", startDate: new Date(), endDate: new Date(), scheduledAt: new Date(), }; - jest.spyOn(reportRepository, 'save').mockResolvedValue(savedReport as any); + jest + .spyOn(reportRepository, "save") + .mockResolvedValue(savedReport as any); const result = await service.generateAdhocReport(merchantId, period); expect(result).toBeDefined(); - expect(typeof result).toBe('string'); // Should return report ID - expect(jest.spyOn(merchantRepository, 'findOne')).toHaveBeenCalledWith({ + expect(typeof result).toBe("string"); // Should return report ID + expect(jest.spyOn(merchantRepository, "findOne")).toHaveBeenCalledWith({ where: { id: merchantId }, }); }); }); - describe('getReportById', () => { - it('should return a report by ID', async () => { - const reportId = 'test-report-id'; + describe("getReportById", () => { + it("should return a report by ID", async () => { + const reportId = "test-report-id"; const expectedReport = { id: reportId, - type: 'weekly', - period: 'weekly', - merchantId: 'test-merchant-id', - status: 'completed', + type: "weekly", + period: "weekly", + merchantId: "test-merchant-id", + status: "completed", startDate: new Date(), endDate: new Date(), } as Report; - jest.spyOn(reportRepository, 'findOne').mockResolvedValue(expectedReport); + jest.spyOn(reportRepository, "findOne").mockResolvedValue(expectedReport); const result = await service.getReportById(reportId); expect(result).toEqual(expectedReport); - expect(jest.spyOn(reportRepository, 'findOne')).toHaveBeenCalledWith({ + expect(jest.spyOn(reportRepository, "findOne")).toHaveBeenCalledWith({ where: { id: reportId }, }); }); }); - describe('getReportHistory', () => { - it('should return report history for a merchant', async () => { - const merchantId = 'test-merchant-id'; + describe("getReportHistory", () => { + it("should return report history for a merchant", async () => { + const merchantId = "test-merchant-id"; const expectedReports = [ { - id: 'report-1', - type: 'weekly', - period: 'weekly', + id: "report-1", + type: "weekly", + period: "weekly", merchantId, - status: 'completed', + status: "completed", startDate: new Date(), endDate: new Date(), }, @@ -149,15 +161,17 @@ describe('ReportService', () => { getMany: jest.fn().mockResolvedValue(expectedReports), }; - jest.spyOn(reportRepository, 'createQueryBuilder').mockReturnValue(queryBuilderMock as any); + jest + .spyOn(reportRepository, "createQueryBuilder") + .mockReturnValue(queryBuilderMock as any); const result = await service.getReportHistory(merchantId); expect(result).toEqual(expectedReports); expect(queryBuilderMock.where).toHaveBeenCalledWith( - 'report.merchantId = :merchantId', - { merchantId } + "report.merchantId = :merchantId", + { merchantId }, ); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api-service/src/reports/controllers/report.controller.ts b/apps/api-service/src/reports/controllers/report.controller.ts index 0ee6f7e..186671f 100644 --- a/apps/api-service/src/reports/controllers/report.controller.ts +++ b/apps/api-service/src/reports/controllers/report.controller.ts @@ -1,124 +1,218 @@ -import { Controller, Get, Post, Query, Param, HttpCode, HttpStatus, Logger, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger'; -import { ReportService } from '../services/report.service'; -import { Report } from '../entities/report.entity'; -import { Roles, ViewerAndAbove, OperatorAndAbove } from '../../rbac/decorators'; -import { RolesGuard } from '../../rbac/guards'; -import { UserRole } from '../../rbac/enums'; - -@ApiTags('Reports') -@Controller('reports') +import { + Controller, + Get, + Post, + Query, + Param, + HttpCode, + HttpStatus, + Logger, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, +} from "@nestjs/swagger"; +import { ReportService } from "../services/report.service"; +import { Report } from "../entities/report.entity"; +import { Roles, ViewerAndAbove, OperatorAndAbove } from "../../rbac/decorators"; +import { RolesGuard } from "../../rbac/guards"; +import { UserRole } from "../../rbac/enums"; + +@ApiTags("Reports") +@Controller("reports") @UseGuards(RolesGuard) export class ReportController { private readonly logger = new Logger(ReportController.name); constructor(private readonly reportService: ReportService) {} - @Post('gas') + @Post("gas") @OperatorAndAbove() - @ApiOperation({ summary: 'Trigger ad-hoc gas report generation' }) - @ApiQuery({ name: 'merchantId', description: 'ID of the merchant to generate report for', required: true }) - @ApiQuery({ name: 'period', description: 'Report period (weekly or monthly)', enum: ['weekly', 'monthly'], required: true }) - @ApiResponse({ status: 200, description: 'Report generation triggered successfully' }) - @ApiResponse({ status: 400, description: 'Invalid parameters' }) - @ApiResponse({ status: 403, description: 'Forbidden - requires operator or admin role' }) + @ApiOperation({ summary: "Trigger ad-hoc gas report generation" }) + @ApiQuery({ + name: "merchantId", + description: "ID of the merchant to generate report for", + required: true, + }) + @ApiQuery({ + name: "period", + description: "Report period (weekly or monthly)", + enum: ["weekly", "monthly"], + required: true, + }) + @ApiResponse({ + status: 200, + description: "Report generation triggered successfully", + }) + @ApiResponse({ status: 400, description: "Invalid parameters" }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires operator or admin role", + }) @HttpCode(HttpStatus.OK) async generateGasReport( - @Query('merchantId') merchantId: string, - @Query('period') period: 'weekly' | 'monthly', + @Query("merchantId") merchantId: string, + @Query("period") period: "weekly" | "monthly", ): Promise<{ reportId: string; message: string }> { try { - this.logger.log(`Request to generate ${period} gas report for merchant ${merchantId}`); - - const reportId = await this.reportService.generateAdhocReport(merchantId, period); - + this.logger.log( + `Request to generate ${period} gas report for merchant ${merchantId}`, + ); + + const reportId = await this.reportService.generateAdhocReport( + merchantId, + period, + ); + return { reportId, - message: `Ad-hoc ${period} gas report generation initiated for merchant ${merchantId}` + message: `Ad-hoc ${period} gas report generation initiated for merchant ${merchantId}`, }; } catch (error) { - this.logger.error(`Error generating ad-hoc gas report for merchant ${merchantId}`, error); + this.logger.error( + `Error generating ad-hoc gas report for merchant ${merchantId}`, + error, + ); throw error; } } - @Get('gas/status/:reportId') + @Get("gas/status/:reportId") @ViewerAndAbove() - @ApiOperation({ summary: 'Check status of a gas report' }) - @ApiParam({ name: 'reportId', description: 'ID of the report to check status for', required: true }) - @ApiResponse({ status: 200, description: 'Report status retrieved successfully', type: Report }) - @ApiResponse({ status: 404, description: 'Report not found' }) - @ApiResponse({ status: 403, description: 'Forbidden - requires authentication' }) - async getReportStatus(@Param('reportId') reportId: string): Promise { + @ApiOperation({ summary: "Check status of a gas report" }) + @ApiParam({ + name: "reportId", + description: "ID of the report to check status for", + required: true, + }) + @ApiResponse({ + status: 200, + description: "Report status retrieved successfully", + type: Report, + }) + @ApiResponse({ status: 404, description: "Report not found" }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires authentication", + }) + async getReportStatus(@Param("reportId") reportId: string): Promise { try { this.logger.log(`Request to check status of report ${reportId}`); - + const report = await this.reportService.getReportById(reportId); - + if (!report) { throw new Error(`Report with ID ${reportId} not found`); } - + return report; } catch (error) { - this.logger.error(`Error getting report status for report ${reportId}`, error); + this.logger.error( + `Error getting report status for report ${reportId}`, + error, + ); throw error; } } - @Get('gas/history') + @Get("gas/history") @ViewerAndAbove() - @ApiOperation({ summary: 'Get report history for a merchant' }) - @ApiQuery({ name: 'merchantId', description: 'ID of the merchant', required: true }) - @ApiQuery({ name: 'period', description: 'Report period (weekly or monthly)', enum: ['weekly', 'monthly'], required: false }) - @ApiQuery({ name: 'limit', description: 'Number of reports to return', required: false, type: Number }) - @ApiResponse({ status: 200, description: 'Report history retrieved successfully', type: [Report] }) - @ApiResponse({ status: 403, description: 'Forbidden - requires authentication' }) + @ApiOperation({ summary: "Get report history for a merchant" }) + @ApiQuery({ + name: "merchantId", + description: "ID of the merchant", + required: true, + }) + @ApiQuery({ + name: "period", + description: "Report period (weekly or monthly)", + enum: ["weekly", "monthly"], + required: false, + }) + @ApiQuery({ + name: "limit", + description: "Number of reports to return", + required: false, + type: Number, + }) + @ApiResponse({ + status: 200, + description: "Report history retrieved successfully", + type: [Report], + }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires authentication", + }) async getReportHistory( - @Query('merchantId') merchantId: string, - @Query('period') period?: 'weekly' | 'monthly', - @Query('limit') limit?: number, + @Query("merchantId") merchantId: string, + @Query("period") period?: "weekly" | "monthly", + @Query("limit") limit?: number, ): Promise { try { - this.logger.log(`Request to get report history for merchant ${merchantId}`); - - return await this.reportService.getReportHistory(merchantId, period, limit ? parseInt(limit.toString()) : 10); + this.logger.log( + `Request to get report history for merchant ${merchantId}`, + ); + + return await this.reportService.getReportHistory( + merchantId, + period, + limit ? parseInt(limit.toString()) : 10, + ); } catch (error) { - this.logger.error(`Error getting report history for merchant ${merchantId}`, error); + this.logger.error( + `Error getting report history for merchant ${merchantId}`, + error, + ); throw error; } } - @Get('gas/download/:reportId') + @Get("gas/download/:reportId") @ViewerAndAbove() - @ApiOperation({ summary: 'Download a generated gas report' }) - @ApiParam({ name: 'reportId', description: 'ID of the report to download', required: true }) - @ApiResponse({ status: 200, description: 'Report downloaded successfully' }) - @ApiResponse({ status: 404, description: 'Report not found or not ready' }) - @ApiResponse({ status: 403, description: 'Forbidden - requires authentication' }) - async downloadReport(@Param('reportId') reportId: string): Promise { + @ApiOperation({ summary: "Download a generated gas report" }) + @ApiParam({ + name: "reportId", + description: "ID of the report to download", + required: true, + }) + @ApiResponse({ status: 200, description: "Report downloaded successfully" }) + @ApiResponse({ status: 404, description: "Report not found or not ready" }) + @ApiResponse({ + status: 403, + description: "Forbidden - requires authentication", + }) + async downloadReport(@Param("reportId") reportId: string): Promise { try { this.logger.log(`Request to download report ${reportId}`); - + const report = await this.reportService.getReportById(reportId); - + if (!report) { throw new Error(`Report with ID ${reportId} not found`); } - - if (report.status !== 'completed') { - throw new Error(`Report with ID ${reportId} is not ready for download. Current status: ${report.status}`); + + if (report.status !== "completed") { + throw new Error( + `Report with ID ${reportId} is not ready for download. Current status: ${report.status}`, + ); } - + if (!report.reportUrl) { - throw new Error(`Report with ID ${reportId} does not have a downloadable file`); + throw new Error( + `Report with ID ${reportId} does not have a downloadable file`, + ); } - + // Return the report file return { reportId: report.id, downloadUrl: report.reportUrl, - fileName: `gas-report-${report.period}-${report.merchantId}-${report.startDate.toISOString().split('T')[0]}.csv`, + fileName: `gas-report-${report.period}-${report.merchantId}-${report.startDate.toISOString().split("T")[0]}.csv`, status: report.status, }; } catch (error) { @@ -126,4 +220,4 @@ export class ReportController { throw error; } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/entities/report.entity.ts b/apps/api-service/src/reports/entities/report.entity.ts index 82cb707..06c7e95 100644 --- a/apps/api-service/src/reports/entities/report.entity.ts +++ b/apps/api-service/src/reports/entities/report.entity.ts @@ -1,59 +1,66 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; -@Entity('reports') +@Entity("reports") export class Report { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ type: 'varchar', length: 100 }) - @Index('idx_report_type') + @Column({ type: "varchar", length: 100 }) + @Index("idx_report_type") type: string; // 'weekly', 'monthly', 'adhoc' - @Column({ type: 'varchar', length: 100 }) - @Index('idx_report_period') + @Column({ type: "varchar", length: 100 }) + @Index("idx_report_period") period: string; // 'weekly', 'monthly' - @Column({ type: 'varchar', length: 100 }) - @Index('idx_report_merchant_id') + @Column({ type: "varchar", length: 100 }) + @Index("idx_report_merchant_id") merchantId: string; - @Column({ type: 'varchar', length: 50, nullable: true }) - @Index('idx_report_chain_id') + @Column({ type: "varchar", length: 50, nullable: true }) + @Index("idx_report_chain_id") chainId?: string; - @Column({ type: 'varchar', length: 50 }) - @Index('idx_report_status') + @Column({ type: "varchar", length: 50 }) + @Index("idx_report_status") status: string; // 'pending', 'processing', 'completed', 'failed' - @Column({ type: 'varchar', length: 500, nullable: true }) + @Column({ type: "varchar", length: 500, nullable: true }) reportUrl?: string; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) reportData?: Record; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: "jsonb", nullable: true }) metadata?: Record; - @Column({ type: 'timestamp' }) - @Index('idx_report_start_date') + @Column({ type: "timestamp" }) + @Index("idx_report_start_date") startDate: Date; - @Column({ type: 'timestamp' }) - @Index('idx_report_end_date') + @Column({ type: "timestamp" }) + @Index("idx_report_end_date") endDate: Date; @CreateDateColumn() - @Index('idx_report_created_at') + @Index("idx_report_created_at") createdAt: Date; @UpdateDateColumn() updatedAt: Date; - @Column({ type: 'timestamp', nullable: true }) - @Index('idx_report_scheduled_at') + @Column({ type: "timestamp", nullable: true }) + @Index("idx_report_scheduled_at") scheduledAt?: Date; - @Column({ type: 'timestamp', nullable: true }) - @Index('idx_report_sent_at') + @Column({ type: "timestamp", nullable: true }) + @Index("idx_report_sent_at") sentAt?: Date; -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/reports.module.ts b/apps/api-service/src/reports/reports.module.ts index 7684d9d..3cf3fb0 100644 --- a/apps/api-service/src/reports/reports.module.ts +++ b/apps/api-service/src/reports/reports.module.ts @@ -1,32 +1,30 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ConfigService } from '@nestjs/config'; -import { Report } from './entities/report.entity'; -import { ReportRepository } from './repositories/report.repository'; -import { ReportService } from './services/report.service'; -import { DataAggregationService } from './services/data-aggregation.service'; -import { ReportGenerationService } from './services/report-generation.service'; -import { EmailNotificationService } from './services/email-notification.service'; -import { SchedulingService } from './services/scheduling.service'; -import { ReportController } from './controllers/report.controller'; -import { Transaction } from '../database/entities/transaction.entity'; -import { Merchant } from '../database/entities/merchant.entity'; -import { Chain } from '../database/entities/chain.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { ConfigService } from "@nestjs/config"; +import { Report } from "./entities/report.entity"; +import { ReportRepository } from "./repositories/report.repository"; +import { ReportService } from "./services/report.service"; +import { DataAggregationService } from "./services/data-aggregation.service"; +import { ReportGenerationService } from "./services/report-generation.service"; +import { EmailNotificationService } from "./services/email-notification.service"; +import { SchedulingService } from "./services/scheduling.service"; +import { ReportController } from "./controllers/report.controller"; +import { Transaction } from "../database/entities/transaction.entity"; +import { Merchant } from "../database/entities/merchant.entity"; +import { Chain } from "../database/entities/chain.entity"; @Module({ imports: [ TypeOrmModule.forFeature([Report, Transaction, Merchant, Chain]), ScheduleModule.forRoot(), // Enable scheduling features ], - controllers: [ - ReportController - ], + controllers: [ReportController], providers: [ ConfigService, // Custom repository provider { - provide: 'ReportRepository', + provide: "ReportRepository", useClass: ReportRepository, }, // Services @@ -36,9 +34,6 @@ import { Chain } from '../database/entities/chain.entity'; EmailNotificationService, SchedulingService, ], - exports: [ - ReportService, - DataAggregationService, - ] + exports: [ReportService, DataAggregationService], }) -export class ReportsModule {} \ No newline at end of file +export class ReportsModule {} diff --git a/apps/api-service/src/reports/repositories/report.repository.ts b/apps/api-service/src/reports/repositories/report.repository.ts index 76d3a37..303ff07 100644 --- a/apps/api-service/src/reports/repositories/report.repository.ts +++ b/apps/api-service/src/reports/repositories/report.repository.ts @@ -1,5 +1,5 @@ -import { EntityRepository, Repository } from 'typeorm'; -import { Report } from '../entities/report.entity'; +import { EntityRepository, Repository } from "typeorm"; +import { Report } from "../entities/report.entity"; @EntityRepository(Report) export class ReportRepository extends Repository { @@ -7,32 +7,35 @@ export class ReportRepository extends Repository { * Find reports by merchant ID and period */ async findByMerchantAndPeriod( - merchantId: string, - period: string, - startDate?: Date, - endDate?: Date + merchantId: string, + period: string, + startDate?: Date, + endDate?: Date, ): Promise { - const query = this.createQueryBuilder('report') - .where('report.merchantId = :merchantId', { merchantId }) - .andWhere('report.period = :period', { period }); + const query = this.createQueryBuilder("report") + .where("report.merchantId = :merchantId", { merchantId }) + .andWhere("report.period = :period", { period }); if (startDate && endDate) { - query.andWhere('report.startDate >= :startDate AND report.endDate <= :endDate', { - startDate, - endDate, - }); + query.andWhere( + "report.startDate >= :startDate AND report.endDate <= :endDate", + { + startDate, + endDate, + }, + ); } - return query.orderBy('report.createdAt', 'DESC').getMany(); + return query.orderBy("report.createdAt", "DESC").getMany(); } /** * Find reports by status */ async findByStatus(status: string): Promise { - return this.createQueryBuilder('report') - .where('report.status = :status', { status }) - .orderBy('report.createdAt', 'ASC') + return this.createQueryBuilder("report") + .where("report.status = :status", { status }) + .orderBy("report.createdAt", "ASC") .getMany(); } @@ -40,21 +43,27 @@ export class ReportRepository extends Repository { * Find pending scheduled reports */ async findPendingScheduledReports(): Promise { - return this.createQueryBuilder('report') - .where('report.type = :type', { type: 'scheduled' }) - .andWhere('report.status = :status', { status: 'pending' }) - .andWhere('(report.scheduledAt IS NULL OR report.scheduledAt <= :now)', { now: new Date() }) + return this.createQueryBuilder("report") + .where("report.type = :type", { type: "scheduled" }) + .andWhere("report.status = :status", { status: "pending" }) + .andWhere("(report.scheduledAt IS NULL OR report.scheduledAt <= :now)", { + now: new Date(), + }) .getMany(); } /** * Update report status */ - async updateReportStatus(reportId: string, status: string, sentAt?: Date): Promise { + async updateReportStatus( + reportId: string, + status: string, + sentAt?: Date, + ): Promise { const updateData: any = { status }; if (sentAt) { updateData.sentAt = sentAt; } await this.update(reportId, updateData); } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/services/data-aggregation.service.ts b/apps/api-service/src/reports/services/data-aggregation.service.ts index ca38481..5b4df35 100644 --- a/apps/api-service/src/reports/services/data-aggregation.service.ts +++ b/apps/api-service/src/reports/services/data-aggregation.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; -import { Transaction } from '../../database/entities/transaction.entity'; -import { Merchant } from '../../database/entities/merchant.entity'; -import { Chain } from '../../database/entities/chain.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from "typeorm"; +import { Transaction } from "../../database/entities/transaction.entity"; +import { Merchant } from "../../database/entities/merchant.entity"; +import { Chain } from "../../database/entities/chain.entity"; @Injectable() export class DataAggregationService { @@ -65,18 +65,21 @@ export class DataAggregationService { let failedTransactions = 0; // Group transactions by chain - const chainMap = new Map(); + const chainMap = new Map< + string, + { + totalGas: number; + totalCostUsd: number; + transactionCount: number; + successfulTransactions: number; + } + >(); for (const transaction of transactions) { totalGasConsumed += Number(transaction.gasUsed || 0); totalGasCostUsd += Number(transaction.transactionFee || 0); - if (transaction.status === 'success') { + if (transaction.status === "success") { successfulTransactions++; } else { failedTransactions++; @@ -96,7 +99,7 @@ export class DataAggregationService { chainData.totalGas += Number(transaction.gasUsed || 0); chainData.totalCostUsd += Number(transaction.transactionFee || 0); chainData.transactionCount++; - if (transaction.status === 'success') { + if (transaction.status === "success") { chainData.successfulTransactions++; } } @@ -104,18 +107,23 @@ export class DataAggregationService { // Get chain names for the breakdown const chainIds = Array.from(chainMap.keys()); const chains = await this.chainRepository.findByIds(chainIds); - const chainNameMap = new Map(chains.map(chain => [chain.id, chain.name])); - - const chainBreakdown = Array.from(chainMap.entries()).map(([chainId, data]) => ({ - chainId, - chainName: chainNameMap.get(chainId) || chainId, - totalGas: data.totalGas, - totalCostUsd: data.totalCostUsd, - transactionCount: data.transactionCount, - successRate: data.transactionCount > 0 - ? (data.successfulTransactions / data.transactionCount) * 100 - : 0, - })); + const chainNameMap = new Map( + chains.map((chain) => [chain.id, chain.name]), + ); + + const chainBreakdown = Array.from(chainMap.entries()).map( + ([chainId, data]) => ({ + chainId, + chainName: chainNameMap.get(chainId) || chainId, + totalGas: data.totalGas, + totalCostUsd: data.totalCostUsd, + transactionCount: data.transactionCount, + successRate: + data.transactionCount > 0 + ? (data.successfulTransactions / data.transactionCount) * 100 + : 0, + }), + ); return { merchantDetails: { @@ -130,13 +138,17 @@ export class DataAggregationService { successMetrics: { successfulTransactions, failedTransactions, - successRate: transactions.length > 0 - ? (successfulTransactions / transactions.length) * 100 - : 0, + successRate: + transactions.length > 0 + ? (successfulTransactions / transactions.length) * 100 + : 0, }, }; } catch (error) { - this.logger.error(`Failed to aggregate gas usage data for merchant ${merchantId}`, error); + this.logger.error( + `Failed to aggregate gas usage data for merchant ${merchantId}`, + error, + ); throw error; } } @@ -164,18 +176,22 @@ export class DataAggregationService { /** * Identify abnormal usage patterns */ - async detectAbnormalUsage(merchantId: string, period: 'weekly' | 'monthly'): Promise { + async detectAbnormalUsage( + merchantId: string, + period: "weekly" | "monthly", + ): Promise { // Get current period data - const currentData = period === 'weekly' - ? await this.getWeeklyGasUsage(merchantId) - : await this.getMonthlyGasUsage(merchantId); + const currentData = + period === "weekly" + ? await this.getWeeklyGasUsage(merchantId) + : await this.getMonthlyGasUsage(merchantId); // Compare with previous period data to detect anomalies // This is a simplified version - in practice, you'd want more sophisticated anomaly detection const previousStartDate = new Date(currentData.startDate); const previousEndDate = new Date(currentData.endDate); - if (period === 'weekly') { + if (period === "weekly") { previousStartDate.setDate(previousStartDate.getDate() - 7); previousEndDate.setDate(previousEndDate.getDate() - 7); } else { @@ -187,7 +203,7 @@ export class DataAggregationService { const previousData = await this.aggregateGasUsageData( merchantId, previousStartDate, - previousEndDate + previousEndDate, ); const anomalies = []; @@ -195,8 +211,8 @@ export class DataAggregationService { // Check for significant increases in gas consumption if (currentData.totalGasConsumed > previousData.totalGasConsumed * 1.5) { anomalies.push({ - type: 'HIGH_GAS_CONSUMPTION', - message: `Gas consumption increased by ${(currentData.totalGasConsumed / previousData.totalGasConsumed * 100 - 100).toFixed(2)}% compared to previous ${period}`, + type: "HIGH_GAS_CONSUMPTION", + message: `Gas consumption increased by ${((currentData.totalGasConsumed / previousData.totalGasConsumed) * 100 - 100).toFixed(2)}% compared to previous ${period}`, currentValue: currentData.totalGasConsumed, previousValue: previousData.totalGasConsumed, }); @@ -206,10 +222,11 @@ export class DataAggregationService { const currentSuccessRate = currentData.successMetrics.successRate; const previousSuccessRate = previousData.successMetrics.successRate; const rateDifference = Math.abs(currentSuccessRate - previousSuccessRate); - - if (rateDifference > 10) { // More than 10% difference + + if (rateDifference > 10) { + // More than 10% difference anomalies.push({ - type: 'SUCCESS_RATE_CHANGE', + type: "SUCCESS_RATE_CHANGE", message: `Transaction success rate changed by ${rateDifference.toFixed(2)}% compared to previous ${period}`, currentValue: currentSuccessRate, previousValue: previousSuccessRate, @@ -218,4 +235,4 @@ export class DataAggregationService { return anomalies; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/services/email-notification.service.ts b/apps/api-service/src/reports/services/email-notification.service.ts index 9e6c720..3ee3b07 100644 --- a/apps/api-service/src/reports/services/email-notification.service.ts +++ b/apps/api-service/src/reports/services/email-notification.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as nodemailer from 'nodemailer'; -import { createTransport, Transporter } from 'nodemailer'; -import { SentMessageInfo } from 'nodemailer'; +import { Injectable, Logger, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as nodemailer from "nodemailer"; +import { createTransport, Transporter } from "nodemailer"; +import { SentMessageInfo } from "nodemailer"; @Injectable() export class EmailNotificationService { @@ -11,14 +11,18 @@ export class EmailNotificationService { constructor(private configService: ConfigService) { // Initialize nodemailer transporter with SMTP configuration - const smtpHost = this.configService.get('SMTP_HOST'); - const smtpPort = parseInt(this.configService.get('SMTP_PORT') || '587'); - const smtpUser = this.configService.get('SMTP_USER'); - const smtpPassword = this.configService.get('SMTP_PASSWORD'); - const smtpSecure = this.configService.get('SMTP_SECURE') || false; + const smtpHost = this.configService.get("SMTP_HOST"); + const smtpPort = parseInt( + this.configService.get("SMTP_PORT") || "587", + ); + const smtpUser = this.configService.get("SMTP_USER"); + const smtpPassword = this.configService.get("SMTP_PASSWORD"); + const smtpSecure = this.configService.get("SMTP_SECURE") || false; if (!smtpHost || !smtpUser || !smtpPassword) { - this.logger.warn('Email service not configured. Missing SMTP configuration.'); + this.logger.warn( + "Email service not configured. Missing SMTP configuration.", + ); return; } @@ -39,44 +43,61 @@ export class EmailNotificationService { async sendGasReportEmail( recipientEmail: string, merchantName: string, - reportType: 'weekly' | 'monthly' | 'adhoc', + reportType: "weekly" | "monthly" | "adhoc", reportFilePath?: string, reportData?: any, ): Promise { if (!this.transporter) { - this.logger.error('Email transporter not initialized. Check SMTP configuration.'); + this.logger.error( + "Email transporter not initialized. Check SMTP configuration.", + ); return false; } try { // Define email subject based on report type - const reportLabel = reportType.charAt(0).toUpperCase() + reportType.slice(1); + const reportLabel = + reportType.charAt(0).toUpperCase() + reportType.slice(1); const subject = `GasGuard ${reportLabel} Gas Usage Report for ${merchantName}`; // Generate email content - const htmlContent = this.generateEmailContent(merchantName, reportType, reportData); + const htmlContent = this.generateEmailContent( + merchantName, + reportType, + reportData, + ); // Prepare mail options const mailOptions = { - from: this.configService.get('SMTP_FROM_EMAIL') || 'noreply@gasguard.com', + from: + this.configService.get("SMTP_FROM_EMAIL") || + "noreply@gasguard.com", to: recipientEmail, subject: subject, html: htmlContent, - attachments: reportFilePath ? [ - { - filename: `gas-report-${reportType}-${new Date().toISOString().split('T')[0]}.csv`, - path: reportFilePath, - } - ] : [], + attachments: reportFilePath + ? [ + { + filename: `gas-report-${reportType}-${new Date().toISOString().split("T")[0]}.csv`, + path: reportFilePath, + }, + ] + : [], }; // Send email - const info: SentMessageInfo = await this.transporter.sendMail(mailOptions); - this.logger.log(`Gas report email sent successfully to ${recipientEmail}. Message ID: ${info.messageId}`); - + const info: SentMessageInfo = + await this.transporter.sendMail(mailOptions); + this.logger.log( + `Gas report email sent successfully to ${recipientEmail}. Message ID: ${info.messageId}`, + ); + return true; } catch (error) { - this.logger.error(`Failed to send gas report email to ${recipientEmail}`, error); + this.logger.error( + `Failed to send gas report email to ${recipientEmail}`, + error, + ); return false; } } @@ -84,9 +105,14 @@ export class EmailNotificationService { /** * Generate HTML content for the email */ - private generateEmailContent(merchantName: string, reportType: string, reportData?: any): string { - const reportLabel = reportType.charAt(0).toUpperCase() + reportType.slice(1); - + private generateEmailContent( + merchantName: string, + reportType: string, + reportData?: any, + ): string { + const reportLabel = + reportType.charAt(0).toUpperCase() + reportType.slice(1); + let content = `

GasGuard ${reportLabel} Gas Usage Report

@@ -99,10 +125,10 @@ export class EmailNotificationService {

Report Summary

    -
  • Total Gas Consumed: ${reportData.totalGasConsumed?.toLocaleString() || 'N/A'}
  • -
  • Total Cost (USD): $${reportData.totalGasCostUsd?.toFixed(2) || 'N/A'}
  • -
  • Transaction Count: ${reportData.transactionCount || 'N/A'}
  • -
  • Success Rate: ${(reportData.successMetrics?.successRate)?.toFixed(2) || 'N/A'}%
  • +
  • Total Gas Consumed: ${reportData.totalGasConsumed?.toLocaleString() || "N/A"}
  • +
  • Total Cost (USD): $${reportData.totalGasCostUsd?.toFixed(2) || "N/A"}
  • +
  • Transaction Count: ${reportData.transactionCount || "N/A"}
  • +
  • Success Rate: ${reportData.successMetrics?.successRate?.toFixed(2) || "N/A"}%
`; @@ -122,9 +148,15 @@ export class EmailNotificationService { /** * Send notification about report generation failure */ - async sendFailureNotification(recipientEmail: string, merchantName: string, error: string): Promise { + async sendFailureNotification( + recipientEmail: string, + merchantName: string, + error: string, + ): Promise { if (!this.transporter) { - this.logger.error('Email transporter not initialized. Check SMTP configuration.'); + this.logger.error( + "Email transporter not initialized. Check SMTP configuration.", + ); return false; } @@ -144,18 +176,26 @@ export class EmailNotificationService { `; const mailOptions = { - from: this.configService.get('SMTP_FROM_EMAIL') || 'noreply@gasguard.com', + from: + this.configService.get("SMTP_FROM_EMAIL") || + "noreply@gasguard.com", to: recipientEmail, subject: subject, html: htmlContent, }; - const info: SentMessageInfo = await this.transporter.sendMail(mailOptions); - this.logger.log(`Failure notification email sent to ${recipientEmail}. Message ID: ${info.messageId}`); - + const info: SentMessageInfo = + await this.transporter.sendMail(mailOptions); + this.logger.log( + `Failure notification email sent to ${recipientEmail}. Message ID: ${info.messageId}`, + ); + return true; } catch (error) { - this.logger.error(`Failed to send failure notification email to ${recipientEmail}`, error); + this.logger.error( + `Failed to send failure notification email to ${recipientEmail}`, + error, + ); return false; } } @@ -170,11 +210,11 @@ export class EmailNotificationService { try { await this.transporter.verify(); - this.logger.log('Email transporter verified successfully'); + this.logger.log("Email transporter verified successfully"); return true; } catch (error) { - this.logger.error('Email transporter verification failed', error); + this.logger.error("Email transporter verification failed", error); return false; } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/services/report-generation.service.ts b/apps/api-service/src/reports/services/report-generation.service.ts index 7fbd40f..3a538aa 100644 --- a/apps/api-service/src/reports/services/report-generation.service.ts +++ b/apps/api-service/src/reports/services/report-generation.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as csv from 'fast-csv'; +import { Injectable, Logger } from "@nestjs/common"; +import * as fs from "fs"; +import * as path from "path"; +import * as csv from "fast-csv"; @Injectable() export class ReportGenerationService { @@ -13,7 +13,7 @@ export class ReportGenerationService { async generateCsvReport(data: any, filename: string): Promise { try { // Create reports directory if it doesn't exist - const reportsDir = path.join(process.cwd(), 'reports'); + const reportsDir = path.join(process.cwd(), "reports"); if (!fs.existsSync(reportsDir)) { fs.mkdirSync(reportsDir, { recursive: true }); } @@ -22,19 +22,19 @@ export class ReportGenerationService { // Prepare CSV data const csvData = []; - + // Add header row csvData.push([ - 'Merchant ID', - 'Merchant Name', - 'Chain ID', - 'Chain Name', - 'Total Gas Used', - 'Total Cost (USD)', - 'Transaction Count', - 'Success Rate (%)', - 'Start Date', - 'End Date' + "Merchant ID", + "Merchant Name", + "Chain ID", + "Chain Name", + "Total Gas Used", + "Total Cost (USD)", + "Transaction Count", + "Success Rate (%)", + "Start Date", + "End Date", ]); // Add data rows @@ -50,7 +50,7 @@ export class ReportGenerationService { chain.transactionCount, chain.successRate.toFixed(2), data.startDate, - data.endDate + data.endDate, ]); } } @@ -58,16 +58,17 @@ export class ReportGenerationService { // Write CSV to file const ws = fs.createWriteStream(filePath); return new Promise((resolve, reject) => { - csv.write(csvData, { headers: false }) + csv + .write(csvData, { headers: false }) .pipe(ws) - .on('finish', () => { + .on("finish", () => { this.logger.log(`CSV report generated: ${filePath}`); resolve(filePath); }) - .on('error', reject); + .on("error", reject); }); } catch (error) { - this.logger.error('Failed to generate CSV report', error); + this.logger.error("Failed to generate CSV report", error); throw error; } } @@ -78,7 +79,7 @@ export class ReportGenerationService { async generateHtmlReport(data: any, filename: string): Promise { try { // Create reports directory if it doesn't exist - const reportsDir = path.join(process.cwd(), 'reports'); + const reportsDir = path.join(process.cwd(), "reports"); if (!fs.existsSync(reportsDir)) { fs.mkdirSync(reportsDir, { recursive: true }); } @@ -128,7 +129,9 @@ export class ReportGenerationService { - ${data.chainBreakdown.map((chain: any) => ` + ${data.chainBreakdown + .map( + (chain: any) => ` ${chain.chainId} ${chain.chainName} @@ -137,18 +140,28 @@ export class ReportGenerationService { ${chain.transactionCount} ${chain.successRate.toFixed(2)}% - `).join('')} + `, + ) + .join("")} - ${data.anomalies && data.anomalies.length > 0 ? ` + ${ + data.anomalies && data.anomalies.length > 0 + ? `

Anomalies Detected

- ${data.anomalies.map((anomaly: any) => ` + ${data.anomalies + .map( + (anomaly: any) => `
${anomaly.type}: ${anomaly.message}
- `).join('')} - ` : ''} + `, + ) + .join("")} + ` + : "" + } `; @@ -157,7 +170,7 @@ export class ReportGenerationService { this.logger.log(`HTML report generated: ${filePath}`); return filePath; } catch (error) { - this.logger.error('Failed to generate HTML report', error); + this.logger.error("Failed to generate HTML report", error); throw error; } } @@ -168,7 +181,7 @@ export class ReportGenerationService { async generateTextReport(data: any, filename: string): Promise { try { // Create reports directory if it doesn't exist - const reportsDir = path.join(process.cwd(), 'reports'); + const reportsDir = path.join(process.cwd(), "reports"); if (!fs.existsSync(reportsDir)) { fs.mkdirSync(reportsDir, { recursive: true }); } @@ -208,8 +221,8 @@ export class ReportGenerationService { this.logger.log(`Text report generated: ${filePath}`); return filePath; } catch (error) { - this.logger.error('Failed to generate text report', error); + this.logger.error("Failed to generate text report", error); throw error; } } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/services/report.service.ts b/apps/api-service/src/reports/services/report.service.ts index 2f715af..6d176ab 100644 --- a/apps/api-service/src/reports/services/report.service.ts +++ b/apps/api-service/src/reports/services/report.service.ts @@ -1,12 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Report } from '../entities/report.entity'; -import { DataAggregationService } from './data-aggregation.service'; -import { ReportGenerationService } from './report-generation.service'; -import { EmailNotificationService } from './email-notification.service'; -import { Merchant } from '../../database/entities/merchant.entity'; -import { v4 as uuidv4 } from 'uuid'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { Report } from "../entities/report.entity"; +import { DataAggregationService } from "./data-aggregation.service"; +import { ReportGenerationService } from "./report-generation.service"; +import { EmailNotificationService } from "./email-notification.service"; +import { Merchant } from "../../database/entities/merchant.entity"; +import { v4 as uuidv4 } from "uuid"; @Injectable() export class ReportService { @@ -25,7 +25,10 @@ export class ReportService { /** * Generate an ad-hoc report for a specific merchant */ - async generateAdhocReport(merchantId: string, period: 'weekly' | 'monthly'): Promise { + async generateAdhocReport( + merchantId: string, + period: "weekly" | "monthly", + ): Promise { try { // Validate merchant exists const merchant = await this.merchantRepository.findOne({ @@ -39,20 +42,20 @@ export class ReportService { // Create a new report record const report = new Report(); report.id = uuidv4(); - report.type = 'adhoc'; + report.type = "adhoc"; report.period = period; report.merchantId = merchantId; - report.status = 'pending'; - + report.status = "pending"; + // Set the date range based on the period - if (period === 'weekly') { + if (period === "weekly") { report.startDate = this.getPreviousWeekStart(); report.endDate = this.getPreviousWeekEnd(); } else { report.startDate = this.getPreviousMonthStart(); report.endDate = this.getPreviousMonthEnd(); } - + report.scheduledAt = new Date(); const savedReport = await this.reportRepository.save(report); @@ -62,7 +65,10 @@ export class ReportService { return savedReport.id; } catch (error) { - this.logger.error(`Error generating ad-hoc report for merchant ${merchantId}`, error); + this.logger.error( + `Error generating ad-hoc report for merchant ${merchantId}`, + error, + ); throw error; } } @@ -77,12 +83,14 @@ export class ReportService { }); if (!report) { - this.logger.error(`Report with ID ${reportId} not found for processing`); + this.logger.error( + `Report with ID ${reportId} not found for processing`, + ); return; } // Update report status to processing - await this.reportRepository.update(reportId, { status: 'processing' }); + await this.reportRepository.update(reportId, { status: "processing" }); // Get merchant details const merchant = await this.merchantRepository.findOne({ @@ -91,84 +99,98 @@ export class ReportService { if (!merchant) { this.logger.error(`Merchant with ID ${report.merchantId} not found`); - await this.reportRepository.update(reportId, { status: 'failed' }); + await this.reportRepository.update(reportId, { status: "failed" }); return; } // Generate aggregated data for the report let reportData; - if (report.period === 'weekly') { - reportData = await this.dataAggregationService.getWeeklyGasUsage(report.merchantId); + if (report.period === "weekly") { + reportData = await this.dataAggregationService.getWeeklyGasUsage( + report.merchantId, + ); } else { - reportData = await this.dataAggregationService.getMonthlyGasUsage(report.merchantId); + reportData = await this.dataAggregationService.getMonthlyGasUsage( + report.merchantId, + ); } // Detect any anomalies in usage const anomalies = await this.dataAggregationService.detectAbnormalUsage( - report.merchantId, - report.period as 'weekly' | 'monthly' + report.merchantId, + report.period as "weekly" | "monthly", ); reportData.anomalies = anomalies; // Generate report file - const fileName = `gas-report-${report.period}-${report.merchantId}-${report.startDate.toISOString().split('T')[0]}.csv`; - const filePath = await this.reportGenerationService.generateCsvReport(reportData, fileName); + const fileName = `gas-report-${report.period}-${report.merchantId}-${report.startDate.toISOString().split("T")[0]}.csv`; + const filePath = await this.reportGenerationService.generateCsvReport( + reportData, + fileName, + ); // Update report with file path and data - await this.reportRepository.update(reportId, { - status: 'processing', + await this.reportRepository.update(reportId, { + status: "processing", reportUrl: filePath, reportData: reportData, }); // Send email notification if merchant has an email if (merchant.email) { - const emailSent = await this.emailNotificationService.sendGasReportEmail( - merchant.email, - merchant.name, - report.period as 'weekly' | 'monthly', - filePath, - reportData - ); + const emailSent = + await this.emailNotificationService.sendGasReportEmail( + merchant.email, + merchant.name, + report.period as "weekly" | "monthly", + filePath, + reportData, + ); if (emailSent) { // Update report status to completed - await this.reportRepository.update(reportId, { - status: 'completed', + await this.reportRepository.update(reportId, { + status: "completed", sentAt: new Date(), }); - - this.logger.log(`Ad-hoc report sent successfully to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.log( + `Ad-hoc report sent successfully to ${merchant.email} for merchant ${merchant.name}`, + ); } else { // Update report status to failed - await this.reportRepository.update(reportId, { - status: 'failed', + await this.reportRepository.update(reportId, { + status: "failed", }); - - this.logger.error(`Failed to send ad-hoc report to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.error( + `Failed to send ad-hoc report to ${merchant.email} for merchant ${merchant.name}`, + ); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - 'Email delivery failed' + "Email delivery failed", ); } } else { // Update report status to completed without sending email - await this.reportRepository.update(reportId, { - status: 'completed', + await this.reportRepository.update(reportId, { + status: "completed", sentAt: new Date(), }); - - this.logger.log(`Ad-hoc report generated successfully for merchant ${merchant.name} but no email sent (no email configured)`); + + this.logger.log( + `Ad-hoc report generated successfully for merchant ${merchant.name} but no email sent (no email configured)`, + ); } } catch (error) { this.logger.error(`Error processing report ${reportId}`, error); // Update report status to failed - await this.reportRepository.update(reportId, { - status: 'failed', + await this.reportRepository.update(reportId, { + status: "failed", }); // Try to get merchant to send failure notification @@ -186,12 +208,15 @@ export class ReportService { await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - error.message + error.message, ); } } } catch (notificationError) { - this.logger.error(`Error sending failure notification`, notificationError); + this.logger.error( + `Error sending failure notification`, + notificationError, + ); } } } @@ -209,17 +234,18 @@ export class ReportService { * Get report history for a merchant */ async getReportHistory( - merchantId: string, - period?: 'weekly' | 'monthly', - limit: number = 10 + merchantId: string, + period?: "weekly" | "monthly", + limit: number = 10, ): Promise { - const query = this.reportRepository.createQueryBuilder('report') - .where('report.merchantId = :merchantId', { merchantId }) - .orderBy('report.createdAt', 'DESC') + const query = this.reportRepository + .createQueryBuilder("report") + .where("report.merchantId = :merchantId", { merchantId }) + .orderBy("report.createdAt", "DESC") .limit(limit); if (period) { - query.andWhere('report.period = :period', { period }); + query.andWhere("report.period = :period", { period }); } return query.getMany(); @@ -229,24 +255,24 @@ export class ReportService { * Get all pending reports */ async getPendingReports(): Promise { - return this.reportRepository.findByStatus('pending'); + return this.reportRepository.findByStatus("pending"); } /** * Retry failed reports */ async retryFailedReports(): Promise { - const failedReports = await this.reportRepository.findByStatus('failed'); + const failedReports = await this.reportRepository.findByStatus("failed"); let processedCount = 0; for (const report of failedReports) { try { // Reset status to pending and reprocess - await this.reportRepository.update(report.id, { - status: 'pending', + await this.reportRepository.update(report.id, { + status: "pending", scheduledAt: new Date(), }); - + // Process the report asynchronously this.processReportAsync(report.id); processedCount++; @@ -265,11 +291,11 @@ export class ReportService { const now = new Date(); const dayOfWeek = now.getUTCDay(); // 0 = Sunday, 1 = Monday, etc. const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1) - 7; // Previous Monday - + const monday = new Date(now); monday.setUTCDate(diff); monday.setUTCHours(0, 0, 0, 0); - + return monday; } @@ -281,7 +307,7 @@ export class ReportService { const endDate = new Date(startDate); endDate.setUTCDate(endDate.getUTCDate() + 6); // Add 6 days to get to Sunday endDate.setUTCHours(23, 59, 59, 999); - + return endDate; } @@ -292,10 +318,10 @@ export class ReportService { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth() - 1; // Previous month - + const startDate = new Date(Date.UTC(year, month, 1)); startDate.setUTCHours(0, 0, 0, 0); - + return startDate; } @@ -306,10 +332,10 @@ export class ReportService { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth(); // Current month (since we want end of previous month) - + const endDate = new Date(Date.UTC(year, month, 0)); // Day 0 is last day of previous month endDate.setUTCHours(23, 59, 59, 999); - + return endDate; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/reports/services/scheduling.service.ts b/apps/api-service/src/reports/services/scheduling.service.ts index bd89484..c4fab94 100644 --- a/apps/api-service/src/reports/services/scheduling.service.ts +++ b/apps/api-service/src/reports/services/scheduling.service.ts @@ -1,13 +1,18 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { DataAggregationService } from './data-aggregation.service'; -import { ReportGenerationService } from './report-generation.service'; -import { EmailNotificationService } from './email-notification.service'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Report } from '../entities/report.entity'; -import { Merchant } from '../../database/entities/merchant.entity'; -import { v4 as uuidv4 } from 'uuid'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { DataAggregationService } from "./data-aggregation.service"; +import { ReportGenerationService } from "./report-generation.service"; +import { EmailNotificationService } from "./email-notification.service"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { Report } from "../entities/report.entity"; +import { Merchant } from "../../database/entities/merchant.entity"; +import { v4 as uuidv4 } from "uuid"; @Injectable() export class SchedulingService implements OnModuleInit, OnModuleDestroy { @@ -26,7 +31,7 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { // Set up recurring tasks if needed - this.logger.log('Scheduling service initialized'); + this.logger.log("Scheduling service initialized"); } async onModuleDestroy() { @@ -46,32 +51,35 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const hour = now.getUTCHours(); // Since CronExpression.EVERY_WEEK runs daily, we need to check if it's Monday - if (dayOfWeek !== 1 || hour < 7 || hour > 9) { // Allow a 2-hour window around 8am + if (dayOfWeek !== 1 || hour < 7 || hour > 9) { + // Allow a 2-hour window around 8am return; } - this.logger.log('Starting weekly report generation process'); + this.logger.log("Starting weekly report generation process"); try { // Get all active merchants const merchants = await this.merchantRepository.find({ - where: { status: 'active' }, + where: { status: "active" }, }); for (const merchant of merchants) { // Skip if merchant doesn't have an email if (!merchant.email) { - this.logger.warn(`Skipping weekly report for merchant ${merchant.name} - no email configured`); + this.logger.warn( + `Skipping weekly report for merchant ${merchant.name} - no email configured`, + ); continue; } // Create a new report record const report = new Report(); report.id = uuidv4(); - report.type = 'scheduled'; - report.period = 'weekly'; + report.type = "scheduled"; + report.period = "weekly"; report.merchantId = merchant.id; - report.status = 'pending'; + report.status = "pending"; report.startDate = this.getPreviousWeekStart(); report.endDate = this.getPreviousWeekEnd(); report.scheduledAt = new Date(); @@ -80,73 +88,89 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { try { // Generate aggregated data for the report - const reportData = await this.dataAggregationService.getWeeklyGasUsage(merchant.id); + const reportData = + await this.dataAggregationService.getWeeklyGasUsage(merchant.id); // Detect any anomalies in usage - const anomalies = await this.dataAggregationService.detectAbnormalUsage(merchant.id, 'weekly'); + const anomalies = + await this.dataAggregationService.detectAbnormalUsage( + merchant.id, + "weekly", + ); reportData.anomalies = anomalies; // Generate report file - const fileName = `weekly-gas-report-${merchant.id}-${report.startDate.toISOString().split('T')[0]}.csv`; - const filePath = await this.reportGenerationService.generateCsvReport(reportData, fileName); + const fileName = `weekly-gas-report-${merchant.id}-${report.startDate.toISOString().split("T")[0]}.csv`; + const filePath = await this.reportGenerationService.generateCsvReport( + reportData, + fileName, + ); // Update report status to processing - await this.reportRepository.update(savedReport.id, { - status: 'processing', + await this.reportRepository.update(savedReport.id, { + status: "processing", reportUrl: filePath, reportData: reportData, }); // Send email notification - const emailSent = await this.emailNotificationService.sendGasReportEmail( - merchant.email, - merchant.name, - 'weekly', - filePath, - reportData - ); + const emailSent = + await this.emailNotificationService.sendGasReportEmail( + merchant.email, + merchant.name, + "weekly", + filePath, + reportData, + ); if (emailSent) { // Update report status to completed - await this.reportRepository.update(savedReport.id, { - status: 'completed', + await this.reportRepository.update(savedReport.id, { + status: "completed", sentAt: new Date(), }); - - this.logger.log(`Weekly report sent successfully to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.log( + `Weekly report sent successfully to ${merchant.email} for merchant ${merchant.name}`, + ); } else { // Update report status to failed - await this.reportRepository.update(savedReport.id, { - status: 'failed', + await this.reportRepository.update(savedReport.id, { + status: "failed", }); - - this.logger.error(`Failed to send weekly report to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.error( + `Failed to send weekly report to ${merchant.email} for merchant ${merchant.name}`, + ); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - 'Email delivery failed' + "Email delivery failed", ); } } catch (error) { - this.logger.error(`Error generating weekly report for merchant ${merchant.name}`, error); + this.logger.error( + `Error generating weekly report for merchant ${merchant.name}`, + error, + ); // Update report status to failed - await this.reportRepository.update(savedReport.id, { - status: 'failed', + await this.reportRepository.update(savedReport.id, { + status: "failed", }); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - error.message + error.message, ); } } } catch (error) { - this.logger.error('Error in weekly report scheduling', error); + this.logger.error("Error in weekly report scheduling", error); } } @@ -161,32 +185,35 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const hour = now.getUTCHours(); // Since CronExpression.EVERY_MONTH runs daily, we need to check if it's the first day - if (dayOfMonth !== 1 || hour < 7 || hour > 9) { // Allow a 2-hour window around 8am + if (dayOfMonth !== 1 || hour < 7 || hour > 9) { + // Allow a 2-hour window around 8am return; } - this.logger.log('Starting monthly report generation process'); + this.logger.log("Starting monthly report generation process"); try { // Get all active merchants const merchants = await this.merchantRepository.find({ - where: { status: 'active' }, + where: { status: "active" }, }); for (const merchant of merchants) { // Skip if merchant doesn't have an email if (!merchant.email) { - this.logger.warn(`Skipping monthly report for merchant ${merchant.name} - no email configured`); + this.logger.warn( + `Skipping monthly report for merchant ${merchant.name} - no email configured`, + ); continue; } // Create a new report record const report = new Report(); report.id = uuidv4(); - report.type = 'scheduled'; - report.period = 'monthly'; + report.type = "scheduled"; + report.period = "monthly"; report.merchantId = merchant.id; - report.status = 'pending'; + report.status = "pending"; report.startDate = this.getPreviousMonthStart(); report.endDate = this.getPreviousMonthEnd(); report.scheduledAt = new Date(); @@ -195,84 +222,101 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { try { // Generate aggregated data for the report - const reportData = await this.dataAggregationService.getMonthlyGasUsage(merchant.id); + const reportData = + await this.dataAggregationService.getMonthlyGasUsage(merchant.id); // Detect any anomalies in usage - const anomalies = await this.dataAggregationService.detectAbnormalUsage(merchant.id, 'monthly'); + const anomalies = + await this.dataAggregationService.detectAbnormalUsage( + merchant.id, + "monthly", + ); reportData.anomalies = anomalies; // Generate report file - const fileName = `monthly-gas-report-${merchant.id}-${report.startDate.toISOString().split('T')[0]}.csv`; - const filePath = await this.reportGenerationService.generateCsvReport(reportData, fileName); + const fileName = `monthly-gas-report-${merchant.id}-${report.startDate.toISOString().split("T")[0]}.csv`; + const filePath = await this.reportGenerationService.generateCsvReport( + reportData, + fileName, + ); // Update report status to processing - await this.reportRepository.update(savedReport.id, { - status: 'processing', + await this.reportRepository.update(savedReport.id, { + status: "processing", reportUrl: filePath, reportData: reportData, }); // Send email notification - const emailSent = await this.emailNotificationService.sendGasReportEmail( - merchant.email, - merchant.name, - 'monthly', - filePath, - reportData - ); + const emailSent = + await this.emailNotificationService.sendGasReportEmail( + merchant.email, + merchant.name, + "monthly", + filePath, + reportData, + ); if (emailSent) { // Update report status to completed - await this.reportRepository.update(savedReport.id, { - status: 'completed', + await this.reportRepository.update(savedReport.id, { + status: "completed", sentAt: new Date(), }); - - this.logger.log(`Monthly report sent successfully to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.log( + `Monthly report sent successfully to ${merchant.email} for merchant ${merchant.name}`, + ); } else { // Update report status to failed - await this.reportRepository.update(savedReport.id, { - status: 'failed', + await this.reportRepository.update(savedReport.id, { + status: "failed", }); - - this.logger.error(`Failed to send monthly report to ${merchant.email} for merchant ${merchant.name}`); + + this.logger.error( + `Failed to send monthly report to ${merchant.email} for merchant ${merchant.name}`, + ); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - 'Email delivery failed' + "Email delivery failed", ); } } catch (error) { - this.logger.error(`Error generating monthly report for merchant ${merchant.name}`, error); + this.logger.error( + `Error generating monthly report for merchant ${merchant.name}`, + error, + ); // Update report status to failed - await this.reportRepository.update(savedReport.id, { - status: 'failed', + await this.reportRepository.update(savedReport.id, { + status: "failed", }); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - error.message + error.message, ); } } } catch (error) { - this.logger.error('Error in monthly report scheduling', error); + this.logger.error("Error in monthly report scheduling", error); } } /** * Process any pending scheduled reports (fallback mechanism) */ - @Cron('0 */30 * * * *') // Every 30 minutes + @Cron("0 */30 * * * *") // Every 30 minutes async processPendingReports() { try { - const pendingReports = await this.reportRepository.findByStatus('pending'); - + const pendingReports = + await this.reportRepository.findByStatus("pending"); + for (const report of pendingReports) { // Check if this report was scheduled to run (compare scheduledAt with current time) if (report.scheduledAt && new Date() > report.scheduledAt) { @@ -281,7 +325,7 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { } } } catch (error) { - this.logger.error('Error processing pending reports', error); + this.logger.error("Error processing pending reports", error); } } @@ -295,31 +339,46 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { }); if (!merchant || !merchant.email) { - this.logger.warn(`Cannot process report for merchant ${report.merchantId} - no merchant found or no email`); - await this.reportRepository.update(report.id, { status: 'failed' }); + this.logger.warn( + `Cannot process report for merchant ${report.merchantId} - no merchant found or no email`, + ); + await this.reportRepository.update(report.id, { status: "failed" }); return; } // Update report status to processing - await this.reportRepository.update(report.id, { status: 'processing' }); + await this.reportRepository.update(report.id, { status: "processing" }); let reportData; - if (report.period === 'weekly') { - reportData = await this.dataAggregationService.getWeeklyGasUsage(report.merchantId); - const anomalies = await this.dataAggregationService.detectAbnormalUsage(report.merchantId, 'weekly'); + if (report.period === "weekly") { + reportData = await this.dataAggregationService.getWeeklyGasUsage( + report.merchantId, + ); + const anomalies = await this.dataAggregationService.detectAbnormalUsage( + report.merchantId, + "weekly", + ); reportData.anomalies = anomalies; } else { - reportData = await this.dataAggregationService.getMonthlyGasUsage(report.merchantId); - const anomalies = await this.dataAggregationService.detectAbnormalUsage(report.merchantId, 'monthly'); + reportData = await this.dataAggregationService.getMonthlyGasUsage( + report.merchantId, + ); + const anomalies = await this.dataAggregationService.detectAbnormalUsage( + report.merchantId, + "monthly", + ); reportData.anomalies = anomalies; } // Generate report file - const fileName = `${report.period}-gas-report-${report.merchantId}-${report.startDate.toISOString().split('T')[0]}.csv`; - const filePath = await this.reportGenerationService.generateCsvReport(reportData, fileName); + const fileName = `${report.period}-gas-report-${report.merchantId}-${report.startDate.toISOString().split("T")[0]}.csv`; + const filePath = await this.reportGenerationService.generateCsvReport( + reportData, + fileName, + ); // Update report with file path - await this.reportRepository.update(report.id, { + await this.reportRepository.update(report.id, { reportUrl: filePath, reportData: reportData, }); @@ -328,40 +387,47 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const emailSent = await this.emailNotificationService.sendGasReportEmail( merchant.email, merchant.name, - report.period as 'weekly' | 'monthly', + report.period as "weekly" | "monthly", filePath, - reportData + reportData, ); if (emailSent) { // Update report status to completed - await this.reportRepository.update(report.id, { - status: 'completed', + await this.reportRepository.update(report.id, { + status: "completed", sentAt: new Date(), }); - - this.logger.log(`Scheduled report sent successfully to ${merchant.email} for merchant ${merchant.id}`); + + this.logger.log( + `Scheduled report sent successfully to ${merchant.email} for merchant ${merchant.id}`, + ); } else { // Update report status to failed - await this.reportRepository.update(report.id, { - status: 'failed', + await this.reportRepository.update(report.id, { + status: "failed", }); - - this.logger.error(`Failed to send scheduled report to ${merchant.email} for merchant ${merchant.id}`); + + this.logger.error( + `Failed to send scheduled report to ${merchant.email} for merchant ${merchant.id}`, + ); // Send failure notification await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - 'Email delivery failed' + "Email delivery failed", ); } } catch (error) { - this.logger.error(`Error processing scheduled report ${report.id}`, error); + this.logger.error( + `Error processing scheduled report ${report.id}`, + error, + ); // Update report status to failed - await this.reportRepository.update(report.id, { - status: 'failed', + await this.reportRepository.update(report.id, { + status: "failed", }); // Get merchant to send failure notification @@ -374,7 +440,7 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { await this.emailNotificationService.sendFailureNotification( merchant.email, merchant.name, - error.message + error.message, ); } } @@ -387,11 +453,11 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const now = new Date(); const dayOfWeek = now.getUTCDay(); // 0 = Sunday, 1 = Monday, etc. const diff = now.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1) - 7; // Previous Monday - + const monday = new Date(now); monday.setUTCDate(diff); monday.setUTCHours(0, 0, 0, 0); - + return monday; } @@ -403,7 +469,7 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const endDate = new Date(startDate); endDate.setUTCDate(endDate.getUTCDate() + 6); // Add 6 days to get to Sunday endDate.setUTCHours(23, 59, 59, 999); - + return endDate; } @@ -414,10 +480,10 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth() - 1; // Previous month - + const startDate = new Date(Date.UTC(year, month, 1)); startDate.setUTCHours(0, 0, 0, 0); - + return startDate; } @@ -428,10 +494,10 @@ export class SchedulingService implements OnModuleInit, OnModuleDestroy { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth(); // Current month (since we want end of previous month) - + const endDate = new Date(Date.UTC(year, month, 0)); // Day 0 is last day of previous month endDate.setUTCHours(23, 59, 59, 999); - + return endDate; } -} \ No newline at end of file +} diff --git a/apps/api-service/src/rules/index.ts b/apps/api-service/src/rules/index.ts index 50cb32f..1f85877 100644 --- a/apps/api-service/src/rules/index.ts +++ b/apps/api-service/src/rules/index.ts @@ -1,4 +1,4 @@ -export * from './rules.module'; -export * from './rules.controller'; -export * from './rules.service'; -export * from './interfaces/rules.interface'; +export * from "./rules.module"; +export * from "./rules.controller"; +export * from "./rules.service"; +export * from "./interfaces/rules.interface"; diff --git a/apps/api-service/src/rules/interfaces/rules.interface.ts b/apps/api-service/src/rules/interfaces/rules.interface.ts index 52fa72c..da358af 100644 --- a/apps/api-service/src/rules/interfaces/rules.interface.ts +++ b/apps/api-service/src/rules/interfaces/rules.interface.ts @@ -1,9 +1,9 @@ -export type RuleSeverity = 'error' | 'warning' | 'info'; +export type RuleSeverity = "error" | "warning" | "info"; export type RuleCategory = - | 'storage-optimization' - | 'gas-optimization' - | 'security' - | 'best-practices'; + | "storage-optimization" + | "gas-optimization" + | "security" + | "best-practices"; export interface RuleDefinition { name: string; diff --git a/apps/api-service/src/rules/rules.controller.ts b/apps/api-service/src/rules/rules.controller.ts index 1304552..439c38d 100644 --- a/apps/api-service/src/rules/rules.controller.ts +++ b/apps/api-service/src/rules/rules.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { RulesService } from './rules.service'; -import { RuleDefinition } from './interfaces/rules.interface'; +import { Controller, Get, Param } from "@nestjs/common"; +import { RulesService } from "./rules.service"; +import { RuleDefinition } from "./interfaces/rules.interface"; -@Controller('rules') +@Controller("rules") export class RulesController { constructor(private readonly rulesService: RulesService) {} @@ -11,8 +11,8 @@ export class RulesController { return this.rulesService.getAllRules(); } - @Get(':name') - getRule(@Param('name') name: string): RuleDefinition | undefined { + @Get(":name") + getRule(@Param("name") name: string): RuleDefinition | undefined { return this.rulesService.getRule(name); } } diff --git a/apps/api-service/src/rules/rules.module.ts b/apps/api-service/src/rules/rules.module.ts index 00736ad..002ce65 100644 --- a/apps/api-service/src/rules/rules.module.ts +++ b/apps/api-service/src/rules/rules.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { RulesController } from './rules.controller'; -import { RulesService } from './rules.service'; +import { Module } from "@nestjs/common"; +import { RulesController } from "./rules.controller"; +import { RulesService } from "./rules.service"; @Module({ controllers: [RulesController], diff --git a/apps/api-service/src/rules/rules.service.ts b/apps/api-service/src/rules/rules.service.ts index 4ad3b25..4545e07 100644 --- a/apps/api-service/src/rules/rules.service.ts +++ b/apps/api-service/src/rules/rules.service.ts @@ -1,47 +1,50 @@ -import { Injectable } from '@nestjs/common'; -import { RuleDefinition } from './interfaces/rules.interface'; -import { RuleViolation } from '../scanner/interfaces/scanner.interface'; +import { Injectable } from "@nestjs/common"; +import { RuleDefinition } from "./interfaces/rules.interface"; +import { RuleViolation } from "../scanner/interfaces/scanner.interface"; @Injectable() export class RulesService { private readonly rules: RuleDefinition[] = [ { - name: 'repeated-external-calls', - description: 'Detects repeated external contract calls and suggests caching call results.', - severity: 'warning', - category: 'security', + name: "repeated-external-calls", + description: + "Detects repeated external contract calls and suggests caching call results.", + severity: "warning", + category: "security", enabled: true, }, { - name: 'unsafe-delegatecall', - description: 'Detects risky delegatecall usage that may execute untrusted logic.', - severity: 'error', - category: 'security', + name: "unsafe-delegatecall", + description: + "Detects risky delegatecall usage that may execute untrusted logic.", + severity: "error", + category: "security", enabled: true, }, { - name: 'large-storage-arrays', - description: 'Detects large or unbounded storage arrays that can increase gas usage.', - severity: 'warning', - category: 'storage-optimization', + name: "large-storage-arrays", + description: + "Detects large or unbounded storage arrays that can increase gas usage.", + severity: "warning", + category: "storage-optimization", enabled: true, }, { - name: 'missing-access-modifiers', - description: 'Detects sensitive public/external functions missing access control checks.', - severity: 'warning', - category: 'security', + name: "missing-access-modifiers", + description: + "Detects sensitive public/external functions missing access control checks.", + severity: "warning", + category: "security", enabled: true, }, { - name: 'unused-state-variables', + name: "unused-state-variables", description: - 'Identifies state variables in Soroban contracts that are never read or written to, helping developers minimize storage footprint and ledger rent.', - severity: 'warning', - category: 'storage-optimization', + "Identifies state variables in Soroban contracts that are never read or written to, helping developers minimize storage footprint and ledger rent.", + severity: "warning", + category: "storage-optimization", enabled: true, - documentationUrl: - 'https://gasguard.dev/rules/unused-state-variables', + documentationUrl: "https://gasguard.dev/rules/unused-state-variables", }, ]; @@ -74,15 +77,15 @@ export class RulesService { code: string, ): Promise { switch (rule.name) { - case 'unused-state-variables': + case "unused-state-variables": return this.checkUnusedStateVariables(code); - case 'repeated-external-calls': + case "repeated-external-calls": return this.checkRepeatedExternalCalls(code); - case 'unsafe-delegatecall': + case "unsafe-delegatecall": return this.checkUnsafeDelegatecall(code); - case 'large-storage-arrays': + case "large-storage-arrays": return this.checkLargeStorageArrays(code); - case 'missing-access-modifiers': + case "missing-access-modifiers": return this.checkMissingAccessModifiers(code); default: return []; @@ -96,12 +99,13 @@ export class RulesService { for (const call of calls) { if (seen.has(call)) { out.push({ - ruleName: 'repeated-external-calls', + ruleName: "repeated-external-calls", description: `Repeated external call pattern detected: ${call.trim()}`, - severity: 'warning', + severity: "warning", lineNumber: 1, columnNumber: 0, - suggestion: 'Cache external call results in local variables when safe.', + suggestion: + "Cache external call results in local variables when safe.", }); break; } @@ -112,27 +116,36 @@ export class RulesService { private checkUnsafeDelegatecall(code: string): RuleViolation[] { if (!/delegatecall\s*\(/.test(code)) return []; - return [{ - ruleName: 'unsafe-delegatecall', - description: 'delegatecall usage detected; validate target and calldata constraints.', - severity: 'error', - lineNumber: 1, - columnNumber: 0, - suggestion: 'Restrict delegatecall targets and avoid user-controlled delegatecall paths.', - }]; + return [ + { + ruleName: "unsafe-delegatecall", + description: + "delegatecall usage detected; validate target and calldata constraints.", + severity: "error", + lineNumber: 1, + columnNumber: 0, + suggestion: + "Restrict delegatecall targets and avoid user-controlled delegatecall paths.", + }, + ]; } private checkLargeStorageArrays(code: string): RuleViolation[] { const out: RuleViolation[] = []; - const storageArrays = code.match(/\w+\s*\[\]\s+(public|private|internal|external)?\s*\w+\s*;/g) || []; + const storageArrays = + code.match( + /\w+\s*\[\]\s+(public|private|internal|external)?\s*\w+\s*;/g, + ) || []; if (storageArrays.length >= 3 || /push\s*\(/.test(code)) { out.push({ - ruleName: 'large-storage-arrays', - description: 'Potential large or unbounded storage array usage detected.', - severity: 'warning', + ruleName: "large-storage-arrays", + description: + "Potential large or unbounded storage array usage detected.", + severity: "warning", lineNumber: 1, columnNumber: 0, - suggestion: 'Consider pagination, bounded collections, or indexing strategies.', + suggestion: + "Consider pagination, bounded collections, or indexing strategies.", }); } return out; @@ -140,20 +153,25 @@ export class RulesService { private checkMissingAccessModifiers(code: string): RuleViolation[] { const out: RuleViolation[] = []; - const risky = /(mint|burn|pause|unpause|upgrade|setAdmin|setOwner|withdraw|clawback)/i; + const risky = + /(mint|burn|pause|unpause|upgrade|setAdmin|setOwner|withdraw|clawback)/i; const fnRe = /function\s+(\w+)\s*\([^)]*\)\s*(public|external)([^{};]*)\{/g; let m: RegExpExecArray | null; while ((m = fnRe.exec(code)) !== null) { const fnName = m[1]; - const tail = m[3] || ''; - if (risky.test(fnName) && !/(onlyOwner|onlyAdmin|require\s*\()/.test(tail)) { + const tail = m[3] || ""; + if ( + risky.test(fnName) && + !/(onlyOwner|onlyAdmin|require\s*\()/.test(tail) + ) { out.push({ - ruleName: 'missing-access-modifiers', + ruleName: "missing-access-modifiers", description: `Sensitive function '${fnName}' may be missing access restrictions.`, - severity: 'warning', + severity: "warning", lineNumber: 1, columnNumber: 0, - suggestion: 'Add explicit access control (e.g., onlyOwner/onlyAdmin) and validation checks.', + suggestion: + "Add explicit access control (e.g., onlyOwner/onlyAdmin) and validation checks.", }); } } @@ -163,8 +181,10 @@ export class RulesService { private checkUnusedStateVariables(code: string): RuleViolation[] { const violations: RuleViolation[] = []; - const structPattern = /#\[contract(?:type|impl)?\]\s*(?:pub\s+)?struct\s+(\w+)\s*\{([^}]*)\}/gs; - const implPattern = /impl\s+(\w+)\s*\{([\s\S]*?)(?=\nimpl|\nstruct|\n#\[|$)/g; + const structPattern = + /#\[contract(?:type|impl)?\]\s*(?:pub\s+)?struct\s+(\w+)\s*\{([^}]*)\}/gs; + const implPattern = + /impl\s+(\w+)\s*\{([\s\S]*?)(?=\nimpl|\nstruct|\n#\[|$)/g; const structs = new Map(); let match: RegExpExecArray | null; @@ -172,7 +192,7 @@ export class RulesService { while ((match = structPattern.exec(code)) !== null) { const structName = match[1]; const fieldsBlock = match[2]; - const lineNumber = code.substring(0, match.index).split('\n').length; + const lineNumber = code.substring(0, match.index).split("\n").length; const fieldPattern = /(?:pub\s+)?(\w+)\s*:/g; const fields: string[] = []; @@ -201,9 +221,9 @@ export class RulesService { for (const field of fields) { if (!usedFields.has(field)) { violations.push({ - ruleName: 'unused-state-variables', + ruleName: "unused-state-variables", description: `State variable '${field}' is declared but never used in contract '${structName}'. This wastes storage space and increases ledger rent costs.`, - severity: 'warning', + severity: "warning", lineNumber, columnNumber: 0, variableName: field, diff --git a/apps/api-service/src/scanner/dto/scan-request.dto.ts b/apps/api-service/src/scanner/dto/scan-request.dto.ts index 82a78f9..27f5414 100644 --- a/apps/api-service/src/scanner/dto/scan-request.dto.ts +++ b/apps/api-service/src/scanner/dto/scan-request.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; export class ScanRequestDto { @IsString() @@ -7,9 +7,9 @@ export class ScanRequestDto { @IsString() @IsOptional() - source?: string = 'remote-scan'; + source?: string = "remote-scan"; @IsString() @IsOptional() - language?: string = 'rust'; + language?: string = "rust"; } diff --git a/apps/api-service/src/scanner/index.ts b/apps/api-service/src/scanner/index.ts index 99001d5..f86ad39 100644 --- a/apps/api-service/src/scanner/index.ts +++ b/apps/api-service/src/scanner/index.ts @@ -1,5 +1,5 @@ -export * from './scanner.module'; -export * from './scanner.controller'; -export * from './scanner.service'; -export * from './dto/scan-request.dto'; -export * from './interfaces/scanner.interface'; +export * from "./scanner.module"; +export * from "./scanner.controller"; +export * from "./scanner.service"; +export * from "./dto/scan-request.dto"; +export * from "./interfaces/scanner.interface"; diff --git a/apps/api-service/src/scanner/interfaces/scanner.interface.ts b/apps/api-service/src/scanner/interfaces/scanner.interface.ts index dbce011..28add71 100644 --- a/apps/api-service/src/scanner/interfaces/scanner.interface.ts +++ b/apps/api-service/src/scanner/interfaces/scanner.interface.ts @@ -1,4 +1,4 @@ -export type ViolationSeverity = 'error' | 'warning' | 'info'; +export type ViolationSeverity = "error" | "warning" | "info"; export interface RuleViolation { ruleName: string; diff --git a/apps/api-service/src/scanner/scanner.controller.ts b/apps/api-service/src/scanner/scanner.controller.ts index 0b89e8d..dd17796 100644 --- a/apps/api-service/src/scanner/scanner.controller.ts +++ b/apps/api-service/src/scanner/scanner.controller.ts @@ -1,22 +1,22 @@ -import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { ScannerService } from './scanner.service'; -import { ScanRequestDto } from './dto/scan-request.dto'; -import { ScanResult } from './interfaces/scanner.interface'; +import { Controller, Post, Body, HttpCode, HttpStatus } from "@nestjs/common"; +import { ScannerService } from "./scanner.service"; +import { ScanRequestDto } from "./dto/scan-request.dto"; +import { ScanResult } from "./interfaces/scanner.interface"; -@Controller('scanner') +@Controller("scanner") export class ScannerController { constructor(private readonly scannerService: ScannerService) {} - @Post('scan') + @Post("scan") @HttpCode(HttpStatus.OK) async scanCode(@Body() scanRequest: ScanRequestDto): Promise { return this.scannerService.scanContent( scanRequest.code, - scanRequest.source ?? 'remote-scan', + scanRequest.source ?? "remote-scan", ); } - @Post('scan-batch') + @Post("scan-batch") @HttpCode(HttpStatus.OK) async scanBatch( @Body() scanRequests: ScanRequestDto[], diff --git a/apps/api-service/src/scanner/scanner.module.ts b/apps/api-service/src/scanner/scanner.module.ts index f97e895..416244c 100644 --- a/apps/api-service/src/scanner/scanner.module.ts +++ b/apps/api-service/src/scanner/scanner.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { ScannerController } from './scanner.controller'; -import { ScannerService } from './scanner.service'; -import { RulesModule } from '../rules/rules.module'; +import { Module } from "@nestjs/common"; +import { ScannerController } from "./scanner.controller"; +import { ScannerService } from "./scanner.service"; +import { RulesModule } from "../rules/rules.module"; @Module({ imports: [RulesModule], diff --git a/apps/api-service/src/scanner/scanner.service.ts b/apps/api-service/src/scanner/scanner.service.ts index d27ce99..409064f 100644 --- a/apps/api-service/src/scanner/scanner.service.ts +++ b/apps/api-service/src/scanner/scanner.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { RulesService } from '../rules/rules.service'; -import { ScanResult, RuleViolation } from './interfaces/scanner.interface'; -import { ScanRequestDto } from './dto/scan-request.dto'; +import { Injectable } from "@nestjs/common"; +import { RulesService } from "../rules/rules.service"; +import { ScanResult, RuleViolation } from "./interfaces/scanner.interface"; +import { ScanRequestDto } from "./dto/scan-request.dto"; @Injectable() export class ScannerService { @@ -25,7 +25,7 @@ export class ScannerService { async scanBatch(requests: ScanRequestDto[]): Promise { const results = await Promise.all( requests.map((req) => - this.scanContent(req.code, req.source ?? 'remote-scan'), + this.scanContent(req.code, req.source ?? "remote-scan"), ), ); return results; @@ -46,13 +46,13 @@ export class ScannerService { for (const violation of violations) { switch (violation.severity) { - case 'error': + case "error": summary.errors++; break; - case 'warning': + case "warning": summary.warnings++; break; - case 'info': + case "info": summary.info++; break; } diff --git a/apps/api-service/src/transection/dto/record-transaction.entity.ts b/apps/api-service/src/transection/dto/record-transaction.entity.ts index 2aa2e5c..6391b1c 100644 --- a/apps/api-service/src/transection/dto/record-transaction.entity.ts +++ b/apps/api-service/src/transection/dto/record-transaction.entity.ts @@ -1,9 +1,6 @@ -import { - IsString, IsNumber, IsEnum, IsOptional, - Min, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { TxStatus, TxType } from '../transaction.entity'; +import { IsString, IsNumber, IsEnum, IsOptional, Min } from "class-validator"; +import { Type } from "class-transformer"; +import { TxStatus, TxType } from "../transaction.entity"; export class RecordTransactionDto { @IsString() @@ -39,4 +36,4 @@ export class RecordTransactionDto { @IsOptional() @IsString() timestamp?: string; -} \ No newline at end of file +} diff --git a/apps/api-service/src/transection/metrics-query.dto.ts b/apps/api-service/src/transection/metrics-query.dto.ts index 60d834c..1eb54f0 100644 --- a/apps/api-service/src/transection/metrics-query.dto.ts +++ b/apps/api-service/src/transection/metrics-query.dto.ts @@ -1,17 +1,27 @@ -import { IsOptional, IsString, IsEnum, IsNumber, Min, Max, Matches } from 'class-validator'; -import { Type } from 'class-transformer'; -import { TxType } from '../transaction.entity'; +import { + IsOptional, + IsString, + IsEnum, + IsNumber, + Min, + Max, + Matches, +} from "class-validator"; +import { Type } from "class-transformer"; +import { TxType } from "../transaction.entity"; export enum Granularity { - DAILY = 'daily', - MONTHLY = 'monthly', + DAILY = "daily", + MONTHLY = "monthly", } export class MetricsQueryDto { /** YYYY-MM or YYYY-MM-DD */ @IsOptional() @IsString() - @Matches(/^\d{4}-\d{2}(-\d{2})?$/, { message: 'period must be YYYY-MM or YYYY-MM-DD' }) + @Matches(/^\d{4}-\d{2}(-\d{2})?$/, { + message: "period must be YYYY-MM or YYYY-MM-DD", + }) period?: string; @IsOptional() @@ -32,4 +42,4 @@ export class AlertQueryDto extends MetricsQueryDto { @Max(100) @Type(() => Number) threshold?: number = 95; -} \ No newline at end of file +} diff --git a/apps/api-service/src/transection/rate-limit.service.spec.ts b/apps/api-service/src/transection/rate-limit.service.spec.ts index 0b44a0a..3018f80 100644 --- a/apps/api-service/src/transection/rate-limit.service.spec.ts +++ b/apps/api-service/src/transection/rate-limit.service.spec.ts @@ -1,76 +1,76 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; -import { RateLimitService } from './rate-limit.service'; +import { HttpException, HttpStatus } from "@nestjs/common"; +import { RateLimitService } from "./rate-limit.service"; -describe('RateLimitService', () => { +describe("RateLimitService", () => { let service: RateLimitService; beforeEach(() => { service = new RateLimitService({ maxPerMinute: 3, maxPerHour: 10 }); }); - describe('check()', () => { - it('allows transactions below per-minute limit', () => { - service.record('M1'); - service.record('M1'); - const status = service.check('M1'); + describe("check()", () => { + it("allows transactions below per-minute limit", () => { + service.record("M1"); + service.record("M1"); + const status = service.check("M1"); expect(status.allowed).toBe(true); expect(status.transactionsLastMinute).toBe(2); }); - it('throws 429 when per-minute limit is reached', () => { - service.record('M1'); - service.record('M1'); - service.record('M1'); - expect(() => service.check('M1')).toThrow(HttpException); + it("throws 429 when per-minute limit is reached", () => { + service.record("M1"); + service.record("M1"); + service.record("M1"); + expect(() => service.check("M1")).toThrow(HttpException); try { - service.check('M1'); + service.check("M1"); } catch (e: any) { expect(e.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(e.getResponse().retryAfterSeconds).toBeGreaterThan(0); } }); - it('throws 429 when per-hour limit is reached', () => { + it("throws 429 when per-hour limit is reached", () => { // Fill the hour bucket without triggering per-minute limit. // Use a fresh service with maxPerMinute=100, maxPerHour=5 const svc = new RateLimitService({ maxPerMinute: 100, maxPerHour: 5 }); - for (let i = 0; i < 5; i++) svc.record('M2'); - expect(() => svc.check('M2')).toThrow(HttpException); + for (let i = 0; i < 5; i++) svc.record("M2"); + expect(() => svc.check("M2")).toThrow(HttpException); }); - it('does not bleed limits between merchants', () => { - service.record('M1'); - service.record('M1'); - service.record('M1'); + it("does not bleed limits between merchants", () => { + service.record("M1"); + service.record("M1"); + service.record("M1"); // M2 should still be fine - expect(() => service.check('M2')).not.toThrow(); + expect(() => service.check("M2")).not.toThrow(); }); }); - describe('getStatus()', () => { - it('returns current counts without throwing', () => { - service.record('M3'); - service.record('M3'); - const status = service.getStatus('M3'); + describe("getStatus()", () => { + it("returns current counts without throwing", () => { + service.record("M3"); + service.record("M3"); + const status = service.getStatus("M3"); expect(status.transactionsLastMinute).toBe(2); expect(status.transactionsLastHour).toBe(2); expect(status.limitPerMinute).toBe(3); expect(status.limitPerHour).toBe(10); }); - it('returns allowed=false when limits are reached, without throwing', () => { - for (let i = 0; i < 3; i++) service.record('M4'); - const status = service.getStatus('M4'); + it("returns allowed=false when limits are reached, without throwing", () => { + for (let i = 0; i < 3; i++) service.record("M4"); + const status = service.getStatus("M4"); expect(status.allowed).toBe(false); }); }); - describe('record()', () => { - it('increments counts', () => { - service.record('M5'); - service.record('M5'); - service.record('M5'); - const status = service.getStatus('M5'); + describe("record()", () => { + it("increments counts", () => { + service.record("M5"); + service.record("M5"); + service.record("M5"); + const status = service.getStatus("M5"); expect(status.transactionsLastMinute).toBe(3); }); }); diff --git a/apps/api-service/src/transection/rate-limit.service.ts b/apps/api-service/src/transection/rate-limit.service.ts index bedc237..1383898 100644 --- a/apps/api-service/src/transection/rate-limit.service.ts +++ b/apps/api-service/src/transection/rate-limit.service.ts @@ -1,4 +1,4 @@ -import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { Injectable, HttpException, HttpStatus, Logger } from "@nestjs/common"; export interface RateLimitConfig { maxPerMinute: number; @@ -59,14 +59,16 @@ export class RateLimitService { if (lastMinute >= this.config.maxPerMinute) { const oldestInWindow = recent.filter((t) => t > oneMinuteAgo)[0]; status.allowed = false; - status.retryAfterSeconds = Math.ceil((oldestInWindow + 60_000 - now) / 1000); + status.retryAfterSeconds = Math.ceil( + (oldestInWindow + 60_000 - now) / 1000, + ); this.logger.warn( `Rate limit (per-minute) exceeded for merchant ${merchantId}: ${lastMinute} tx in last 60s`, ); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, - error: 'Too Many Requests', + error: "Too Many Requests", message: `Rate limit exceeded: ${lastMinute}/${this.config.maxPerMinute} transactions in the last minute.`, retryAfterSeconds: status.retryAfterSeconds, }, @@ -77,14 +79,16 @@ export class RateLimitService { if (lastHour >= this.config.maxPerHour) { const oldestInWindow = recent[0]; status.allowed = false; - status.retryAfterSeconds = Math.ceil((oldestInWindow + 3_600_000 - now) / 1000); + status.retryAfterSeconds = Math.ceil( + (oldestInWindow + 3_600_000 - now) / 1000, + ); this.logger.warn( `Rate limit (per-hour) exceeded for merchant ${merchantId}: ${lastHour} tx in last 60min`, ); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, - error: 'Too Many Requests', + error: "Too Many Requests", message: `Rate limit exceeded: ${lastHour}/${this.config.maxPerHour} transactions in the last hour.`, retryAfterSeconds: status.retryAfterSeconds, }, @@ -119,7 +123,9 @@ export class RateLimitService { const lastHour = recent.length; return { - allowed: lastMinute < this.config.maxPerMinute && lastHour < this.config.maxPerHour, + allowed: + lastMinute < this.config.maxPerMinute && + lastHour < this.config.maxPerHour, merchantId, transactionsLastMinute: lastMinute, transactionsLastHour: lastHour, diff --git a/apps/api-service/src/transection/refund-priority.service.ts b/apps/api-service/src/transection/refund-priority.service.ts index acbff23..2bbc55c 100644 --- a/apps/api-service/src/transection/refund-priority.service.ts +++ b/apps/api-service/src/transection/refund-priority.service.ts @@ -4,18 +4,18 @@ import { ForbiddenException, NotFoundException, Logger, -} from '@nestjs/common'; -import { UserRole } from '../rbac/enums/role.enum'; +} from "@nestjs/common"; +import { UserRole } from "../rbac/enums/role.enum"; -export type RefundPriority = 'low' | 'medium' | 'high' | 'critical'; -export type RefundStatus = 'queued' | 'processing' | 'completed' | 'cancelled'; +export type RefundPriority = "low" | "medium" | "high" | "critical"; +export type RefundStatus = "queued" | "processing" | "completed" | "cancelled"; export type RefundReason = - | 'failed_transaction' - | 'dispute' - | 'duplicate_charge' - | 'vip_request' - | 'sla_breach' - | 'standard'; + | "failed_transaction" + | "dispute" + | "duplicate_charge" + | "vip_request" + | "sla_breach" + | "standard"; export interface RefundRequest { id: string; @@ -33,12 +33,12 @@ export interface RefundRequest { } const AUTO_PRIORITY_RULES: Record = { - failed_transaction: 'critical', - dispute: 'high', - duplicate_charge: 'high', - sla_breach: 'high', - vip_request: 'medium', - standard: 'low', + failed_transaction: "critical", + dispute: "high", + duplicate_charge: "high", + sla_breach: "high", + vip_request: "medium", + standard: "low", }; const PRIORITY_WEIGHT: Record = { @@ -70,11 +70,11 @@ export class RefundPriorityService { reason: RefundReason, ): RefundRequest { if (amount <= 0) { - throw new BadRequestException('Refund amount must be greater than zero'); + throw new BadRequestException("Refund amount must be greater than zero"); } if (!transactionId?.trim()) { - throw new BadRequestException('transactionId is required'); + throw new BadRequestException("transactionId is required"); } const priority = AUTO_PRIORITY_RULES[reason]; @@ -88,15 +88,17 @@ export class RefundPriorityService { currency, reason, priority, - status: 'queued', + status: "queued", createdAt: new Date(), }; this.queue.push(request); this.sortQueue(); - this.record(request.id, 'submitted', userId, { reason, priority }); - this.logger.log(`Refund ${request.id} queued with priority=${priority} reason=${reason}`); + this.record(request.id, "submitted", userId, { reason, priority }); + this.logger.log( + `Refund ${request.id} queued with priority=${priority} reason=${reason}`, + ); return { ...request }; } @@ -107,8 +109,13 @@ export class RefundPriorityService { requestedBy: string, requestedByRole: UserRole, ): RefundRequest { - if (requestedByRole !== UserRole.ADMIN && requestedByRole !== UserRole.OPERATOR) { - throw new ForbiddenException('Only ADMIN or OPERATOR can override refund priority'); + if ( + requestedByRole !== UserRole.ADMIN && + requestedByRole !== UserRole.OPERATOR + ) { + throw new ForbiddenException( + "Only ADMIN or OPERATOR can override refund priority", + ); } const request = this.findActive(refundId); @@ -118,26 +125,33 @@ export class RefundPriorityService { request.priorityOverriddenBy = requestedBy; this.sortQueue(); - this.record(refundId, 'priority_override', requestedBy, { from: previous, to: newPriority }); - this.logger.warn(`Refund ${refundId} priority changed ${previous} → ${newPriority} by ${requestedBy}`); + this.record(refundId, "priority_override", requestedBy, { + from: previous, + to: newPriority, + }); + this.logger.warn( + `Refund ${refundId} priority changed ${previous} → ${newPriority} by ${requestedBy}`, + ); return { ...request }; } processNext(): RefundRequest | null { - const next = this.queue.find((r) => r.status === 'queued'); + const next = this.queue.find((r) => r.status === "queued"); if (!next) return null; - next.status = 'processing'; + next.status = "processing"; // Simulate synchronous completion; real implementation hooks into payment processor - next.status = 'completed'; + next.status = "completed"; next.processedAt = new Date(); const idx = this.queue.indexOf(next); if (idx !== -1) this.queue.splice(idx, 1); - this.record(next.id, 'processed', 'system', { processedAt: next.processedAt }); + this.record(next.id, "processed", "system", { + processedAt: next.processedAt, + }); this.logger.log(`Refund ${next.id} processed (priority=${next.priority})`); return { ...next }; @@ -155,19 +169,23 @@ export class RefundPriorityService { cancel(refundId: string, cancelledBy: string, role: UserRole): RefundRequest { if (role !== UserRole.ADMIN && role !== UserRole.OPERATOR) { - throw new ForbiddenException('Only ADMIN or OPERATOR can cancel a refund'); + throw new ForbiddenException( + "Only ADMIN or OPERATOR can cancel a refund", + ); } const request = this.findActive(refundId); - if (request.status !== 'queued') { - throw new BadRequestException(`Cannot cancel refund in status: ${request.status}`); + if (request.status !== "queued") { + throw new BadRequestException( + `Cannot cancel refund in status: ${request.status}`, + ); } - request.status = 'cancelled'; + request.status = "cancelled"; const idx = this.queue.indexOf(request); if (idx !== -1) this.queue.splice(idx, 1); - this.record(refundId, 'cancelled', cancelledBy); + this.record(refundId, "cancelled", cancelledBy); return { ...request }; } @@ -185,16 +203,17 @@ export class RefundPriorityService { return { queueLength: this.queue.length, - byCritical: byPriority('critical'), - byHigh: byPriority('high'), - byMedium: byPriority('medium'), - byLow: byPriority('low'), + byCritical: byPriority("critical"), + byHigh: byPriority("high"), + byMedium: byPriority("medium"), + byLow: byPriority("low"), }; } private findActive(refundId: string): RefundRequest { const r = this.queue.find((x) => x.id === refundId); - if (!r) throw new NotFoundException(`Refund ${refundId} not found in queue`); + if (!r) + throw new NotFoundException(`Refund ${refundId} not found in queue`); return r; } diff --git a/apps/api-service/src/transection/suspicious-activity.service.spec.ts b/apps/api-service/src/transection/suspicious-activity.service.spec.ts index aeb8280..3e90faf 100644 --- a/apps/api-service/src/transection/suspicious-activity.service.spec.ts +++ b/apps/api-service/src/transection/suspicious-activity.service.spec.ts @@ -1,11 +1,15 @@ -import { SuspiciousActivityService, SuspiciousActivityType, AlertSeverity } from './suspicious-activity.service'; -import { Transaction, TxStatus, TxType } from './transaction.entity'; +import { + SuspiciousActivityService, + SuspiciousActivityType, + AlertSeverity, +} from "./suspicious-activity.service"; +import { Transaction, TxStatus, TxType } from "./transaction.entity"; function makeTx(overrides: Partial = {}): Transaction { return Object.assign(new Transaction(), { id: crypto.randomUUID(), txHash: `0x${Math.random().toString(16).slice(2)}`, - merchantId: 'merchant_1', + merchantId: "merchant_1", chainId: 1, status: TxStatus.SUCCESS, type: TxType.TRANSFER, @@ -15,22 +19,22 @@ function makeTx(overrides: Partial = {}): Transaction { }); } -describe('SuspiciousActivityService', () => { +describe("SuspiciousActivityService", () => { let service: SuspiciousActivityService; beforeEach(() => { service = new SuspiciousActivityService(); }); - describe('analyze() — no detection below threshold', () => { - it('returns detected=false when fewer than 5 transactions', () => { + describe("analyze() — no detection below threshold", () => { + it("returns detected=false when fewer than 5 transactions", () => { for (let i = 0; i < 4; i++) { const result = service.analyze(makeTx()); expect(result.detected).toBe(false); } }); - it('returns detected=false for normal traffic', () => { + it("returns detected=false for normal traffic", () => { for (let i = 0; i < 10; i++) { const tx = makeTx({ timestamp: new Date(Date.now() - i * 5000) }); service.analyze(tx); @@ -40,12 +44,16 @@ describe('SuspiciousActivityService', () => { }); }); - describe('High failure rate detection', () => { - it('detects HIGH_FAILURE_RATE when > 70% of last 10 transactions fail', () => { + describe("High failure rate detection", () => { + it("detects HIGH_FAILURE_RATE when > 70% of last 10 transactions fail", () => { // Seed 10 transactions with 8 failures const txs = [ - ...Array(8).fill(null).map(() => makeTx({ status: TxStatus.FAILURE })), - ...Array(2).fill(null).map(() => makeTx({ status: TxStatus.SUCCESS })), + ...Array(8) + .fill(null) + .map(() => makeTx({ status: TxStatus.FAILURE })), + ...Array(2) + .fill(null) + .map(() => makeTx({ status: TxStatus.SUCCESS })), ]; let lastResult: any; for (const tx of txs) { @@ -53,15 +61,23 @@ describe('SuspiciousActivityService', () => { } expect(lastResult.detected).toBe(true); expect(lastResult.type).toBe(SuspiciousActivityType.HIGH_FAILURE_RATE); - expect([AlertSeverity.LOW, AlertSeverity.MEDIUM, AlertSeverity.HIGH]).toContain(lastResult.severity); + expect([ + AlertSeverity.LOW, + AlertSeverity.MEDIUM, + AlertSeverity.HIGH, + ]).toContain(lastResult.severity); expect(lastResult.detectedAt).toBeDefined(); expect(lastResult.metadata.failureRate).toBeGreaterThanOrEqual(70); }); - it('does not flag when failure rate is below 70%', () => { + it("does not flag when failure rate is below 70%", () => { const txs = [ - ...Array(6).fill(null).map(() => makeTx({ status: TxStatus.SUCCESS })), - ...Array(4).fill(null).map(() => makeTx({ status: TxStatus.FAILURE })), + ...Array(6) + .fill(null) + .map(() => makeTx({ status: TxStatus.SUCCESS })), + ...Array(4) + .fill(null) + .map(() => makeTx({ status: TxStatus.FAILURE })), ]; let lastResult: any; for (const tx of txs) { @@ -69,13 +85,15 @@ describe('SuspiciousActivityService', () => { } // 40% failure — should NOT trigger high failure rate if (lastResult.detected) { - expect(lastResult.type).not.toBe(SuspiciousActivityType.HIGH_FAILURE_RATE); + expect(lastResult.type).not.toBe( + SuspiciousActivityType.HIGH_FAILURE_RATE, + ); } }); }); - describe('Gas spike detection', () => { - it('detects GAS_SPIKE when latest tx uses > 4x rolling average', () => { + describe("Gas spike detection", () => { + it("detects GAS_SPIKE when latest tx uses > 4x rolling average", () => { // 9 normal transactions for (let i = 0; i < 9; i++) { service.analyze(makeTx({ gasUsed: 21000 })); @@ -88,7 +106,7 @@ describe('SuspiciousActivityService', () => { expect(result.metadata?.spikeMultiplier).toBeGreaterThanOrEqual(4); }); - it('does not flag a normal gas amount', () => { + it("does not flag a normal gas amount", () => { for (let i = 0; i < 9; i++) { service.analyze(makeTx({ gasUsed: 21000 })); } @@ -99,12 +117,14 @@ describe('SuspiciousActivityService', () => { }); }); - describe('Burst detection', () => { - it('detects BURST_TRANSACTIONS when >= 5 tx occur within 10 seconds', () => { + describe("Burst detection", () => { + it("detects BURST_TRANSACTIONS when >= 5 tx occur within 10 seconds", () => { const now = Date.now(); - const txs = Array(6).fill(null).map((_, i) => - makeTx({ timestamp: new Date(now - i * 1000) }), // 1s apart within 10s - ); + const txs = Array(6) + .fill(null) + .map( + (_, i) => makeTx({ timestamp: new Date(now - i * 1000) }), // 1s apart within 10s + ); let lastResult: any; for (const tx of txs) { lastResult = service.analyze(tx); @@ -114,18 +134,20 @@ describe('SuspiciousActivityService', () => { expect(lastResult.metadata?.burstCount).toBeGreaterThanOrEqual(5); }); - it('does not flag transactions spread over time', () => { + it("does not flag transactions spread over time", () => { const now = Date.now(); // Spread 10 transactions over 2 minutes — never > 5 in 10s - const txs = Array(10).fill(null).map((_, i) => - makeTx({ timestamp: new Date(now - i * 15000) }), - ); + const txs = Array(10) + .fill(null) + .map((_, i) => makeTx({ timestamp: new Date(now - i * 15000) })); let lastResult: any; for (const tx of txs) { lastResult = service.analyze(tx); } if (lastResult.detected) { - expect(lastResult.type).not.toBe(SuspiciousActivityType.BURST_TRANSACTIONS); + expect(lastResult.type).not.toBe( + SuspiciousActivityType.BURST_TRANSACTIONS, + ); } }); }); diff --git a/apps/api-service/src/transection/suspicious-activity.service.ts b/apps/api-service/src/transection/suspicious-activity.service.ts index 575ef18..d2fa1d0 100644 --- a/apps/api-service/src/transection/suspicious-activity.service.ts +++ b/apps/api-service/src/transection/suspicious-activity.service.ts @@ -1,16 +1,16 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Transaction, TxStatus } from './transaction.entity'; +import { Injectable, Logger } from "@nestjs/common"; +import { Transaction, TxStatus } from "./transaction.entity"; export enum SuspiciousActivityType { - HIGH_FAILURE_RATE = 'HIGH_FAILURE_RATE', - BURST_TRANSACTIONS = 'BURST_TRANSACTIONS', - GAS_SPIKE = 'GAS_SPIKE', + HIGH_FAILURE_RATE = "HIGH_FAILURE_RATE", + BURST_TRANSACTIONS = "BURST_TRANSACTIONS", + GAS_SPIKE = "GAS_SPIKE", } export enum AlertSeverity { - LOW = 'low', - MEDIUM = 'medium', - HIGH = 'high', + LOW = "low", + MEDIUM = "medium", + HIGH = "high", } export interface SuspiciousActivityAlert { @@ -50,7 +50,11 @@ export class SuspiciousActivityService { const window = this.windows.get(key) ?? []; // Keep a rolling window of the last 50 transactions - window.push({ timestamp: tx.timestamp.getTime(), status: tx.status, gasUsed: Number(tx.gasUsed) }); + window.push({ + timestamp: tx.timestamp.getTime(), + status: tx.status, + gasUsed: Number(tx.gasUsed), + }); if (window.length > 50) window.shift(); this.windows.set(key, window); @@ -69,7 +73,11 @@ export class SuspiciousActivityService { return burstAlert; } - const failureAlert = this.detectHighFailureRate(tx.merchantId, tx.chainId, window); + const failureAlert = this.detectHighFailureRate( + tx.merchantId, + tx.chainId, + window, + ); if (failureAlert.detected) { this.emitAlert(failureAlert); return failureAlert; @@ -97,9 +105,12 @@ export class SuspiciousActivityService { const burst = window.filter((w) => w.timestamp >= tenSecondsAgo); if (burst.length >= 5) { - const severity = burst.length >= 15 ? AlertSeverity.HIGH - : burst.length >= 8 ? AlertSeverity.MEDIUM - : AlertSeverity.LOW; + const severity = + burst.length >= 15 + ? AlertSeverity.HIGH + : burst.length >= 8 + ? AlertSeverity.MEDIUM + : AlertSeverity.LOW; return { detected: true, @@ -131,9 +142,12 @@ export class SuspiciousActivityService { const failureRate = failures / recent.length; if (failureRate >= 0.7) { - const severity = failureRate >= 0.9 ? AlertSeverity.HIGH - : failureRate >= 0.8 ? AlertSeverity.MEDIUM - : AlertSeverity.LOW; + const severity = + failureRate >= 0.9 + ? AlertSeverity.HIGH + : failureRate >= 0.8 + ? AlertSeverity.MEDIUM + : AlertSeverity.LOW; return { detected: true, @@ -172,9 +186,12 @@ export class SuspiciousActivityService { const ratio = latest.gasUsed / avgGas; if (ratio >= 4) { - const severity = ratio >= 10 ? AlertSeverity.HIGH - : ratio >= 6 ? AlertSeverity.MEDIUM - : AlertSeverity.LOW; + const severity = + ratio >= 10 + ? AlertSeverity.HIGH + : ratio >= 6 + ? AlertSeverity.MEDIUM + : AlertSeverity.LOW; return { detected: true, @@ -198,7 +215,7 @@ export class SuspiciousActivityService { private emitAlert(alert: SuspiciousActivityAlert): void { this.logger.warn( `[SUSPICIOUS_ACTIVITY] merchant=${alert.merchantId} chain=${alert.chainId} ` + - `type=${alert.type} severity=${alert.severity} — ${alert.message}`, + `type=${alert.type} severity=${alert.severity} — ${alert.message}`, alert.metadata, ); } diff --git a/apps/api-service/src/transection/transaction.entity.ts b/apps/api-service/src/transection/transaction.entity.ts index a1803d0..d41a942 100644 --- a/apps/api-service/src/transection/transaction.entity.ts +++ b/apps/api-service/src/transection/transaction.entity.ts @@ -4,50 +4,55 @@ import { Column, CreateDateColumn, Index, -} from 'typeorm'; +} from "typeorm"; export enum TxStatus { - SUCCESS = 'success', - FAILURE = 'failure', - REVERTED = 'reverted', + SUCCESS = "success", + FAILURE = "failure", + REVERTED = "reverted", } export enum TxType { - TRANSFER = 'transfer', - SWAP = 'swap', - CONTRACT_CALL = 'contract_call', + TRANSFER = "transfer", + SWAP = "swap", + CONTRACT_CALL = "contract_call", } -@Entity('transactions') +@Entity("transactions") export class Transaction { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; - @Column({ name: 'tx_hash', unique: true }) + @Column({ name: "tx_hash", unique: true }) txHash: string; - @Column({ name: 'merchant_id' }) + @Column({ name: "merchant_id" }) @Index() merchantId: string; - @Column({ name: 'chain_id' }) + @Column({ name: "chain_id" }) chainId: number; - @Column({ type: 'enum', enum: TxStatus }) + @Column({ type: "enum", enum: TxStatus }) status: TxStatus; - @Column({ type: 'enum', enum: TxType }) + @Column({ type: "enum", enum: TxType }) type: TxType; - @Column({ name: 'gas_used', type: 'bigint' }) + @Column({ name: "gas_used", type: "bigint" }) gasUsed: number; - @Column({ name: 'gas_price', type: 'varchar', nullable: true }) + @Column({ name: "gas_price", type: "varchar", nullable: true }) gasPrice: string; - @Column({ name: 'from_address', type: 'varchar', length: 255, nullable: true }) + @Column({ + name: "from_address", + type: "varchar", + length: 255, + nullable: true, + }) fromAddress: string; - @CreateDateColumn({ name: 'timestamp', type: 'timestamptz' }) + @CreateDateColumn({ name: "timestamp", type: "timestamptz" }) timestamp: Date; -} \ No newline at end of file +} diff --git a/apps/api-service/src/transection/transactions.controller.ts b/apps/api-service/src/transection/transactions.controller.ts index 40d62c5..ea1b7e4 100644 --- a/apps/api-service/src/transection/transactions.controller.ts +++ b/apps/api-service/src/transection/transactions.controller.ts @@ -11,7 +11,11 @@ import { } from "@nestjs/common"; import { TransactionsService } from "./transactions.service"; import { RecordTransactionDto } from "./dto/record-transaction.entity"; -import { AlertQueryDto, MetricsQueryDto, TimeSeriesQueryDto } from "./metrics-query.dto"; +import { + AlertQueryDto, + MetricsQueryDto, + TimeSeriesQueryDto, +} from "./metrics-query.dto"; import { RateLimitService } from "./rate-limit.service"; @Controller("api/v1") diff --git a/apps/api-service/src/transection/transactions.module.ts b/apps/api-service/src/transection/transactions.module.ts index 54dc5b8..9869be4 100644 --- a/apps/api-service/src/transection/transactions.module.ts +++ b/apps/api-service/src/transection/transactions.module.ts @@ -1,19 +1,16 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuditModule } from '../audit'; -import { Transaction } from './transaction.entity'; -import { TransactionsService } from './transactions.service'; -import { TransactionsController } from './transactions.controller'; -import { RateLimitService } from './rate-limit.service'; -import { SuspiciousActivityService } from './suspicious-activity.service'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuditModule } from "../audit"; +import { Transaction } from "./transaction.entity"; +import { TransactionsService } from "./transactions.service"; +import { TransactionsController } from "./transactions.controller"; +import { RateLimitService } from "./rate-limit.service"; +import { SuspiciousActivityService } from "./suspicious-activity.service"; @Module({ - imports: [ - TypeOrmModule.forFeature([Transaction]), - AuditModule, - ], + imports: [TypeOrmModule.forFeature([Transaction]), AuditModule], providers: [TransactionsService, RateLimitService, SuspiciousActivityService], controllers: [TransactionsController], exports: [TransactionsService, RateLimitService, SuspiciousActivityService], }) -export class TransactionsModule {} \ No newline at end of file +export class TransactionsModule {} diff --git a/apps/api-service/src/transection/transactions.service.ts b/apps/api-service/src/transection/transactions.service.ts index 2f09e30..9952fa6 100644 --- a/apps/api-service/src/transection/transactions.service.ts +++ b/apps/api-service/src/transection/transactions.service.ts @@ -3,9 +3,17 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { Transaction, TxStatus } from "./transaction.entity"; import { RecordTransactionDto } from "./dto/record-transaction.entity"; -import { AlertQueryDto, Granularity, MetricsQueryDto, TimeSeriesQueryDto } from "./metrics-query.dto"; +import { + AlertQueryDto, + Granularity, + MetricsQueryDto, + TimeSeriesQueryDto, +} from "./metrics-query.dto"; import { RateLimitService, RateLimitStatus } from "./rate-limit.service"; -import { SuspiciousActivityService, SuspiciousActivityAlert } from "./suspicious-activity.service"; +import { + SuspiciousActivityService, + SuspiciousActivityAlert, +} from "./suspicious-activity.service"; import { AuditLogService } from "../audit"; function parsePeriod(period: string): { start: Date; end: Date } { @@ -141,13 +149,19 @@ export class TransactionsService { const txs = await qb.orderBy("tx.timestamp", "ASC").getMany(); const total = txs.length; - const successful = txs.filter((t: any) => t.status === TxStatus.SUCCESS).length; + const successful = txs.filter( + (t: any) => t.status === TxStatus.SUCCESS, + ).length; const failed = txs.filter((t: any) => t.status === TxStatus.FAILURE).length; - const reverted = txs.filter((t: any) => t.status === TxStatus.REVERTED).length; + const reverted = txs.filter( + (t: any) => t.status === TxStatus.REVERTED, + ).length; const avgGas = total === 0 ? null - : Math.round(txs.reduce((s: any, t: any) => s + Number(t.gasUsed), 0) / total); + : Math.round( + txs.reduce((s: any, t: any) => s + Number(t.gasUsed), 0) / total, + ); return { txs, total, successful, failed, reverted, avgGas }; } diff --git a/apps/api-service/src/transection/withdrawal-queue.service.ts b/apps/api-service/src/transection/withdrawal-queue.service.ts index baa6cad..07c5b74 100644 --- a/apps/api-service/src/transection/withdrawal-queue.service.ts +++ b/apps/api-service/src/transection/withdrawal-queue.service.ts @@ -4,10 +4,14 @@ import { ConflictException, NotFoundException, Logger, -} from '@nestjs/common'; +} from "@nestjs/common"; -export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'failed'; -export type WithdrawalPriority = 'low' | 'medium' | 'high' | 'critical'; +export type WithdrawalStatus = + | "pending" + | "processing" + | "completed" + | "failed"; +export type WithdrawalPriority = "low" | "medium" | "high" | "critical"; export interface WithdrawalRequest { id: string; @@ -45,10 +49,12 @@ export class WithdrawalQueueService { merchantId: string, amount: number, currency: string, - priority: WithdrawalPriority = 'medium', + priority: WithdrawalPriority = "medium", ): WithdrawalRequest { if (amount <= 0) { - throw new BadRequestException('Withdrawal amount must be greater than zero'); + throw new BadRequestException( + "Withdrawal amount must be greater than zero", + ); } const duplicate = this.queue.find( @@ -56,7 +62,7 @@ export class WithdrawalQueueService { r.userId === userId && r.amount === amount && r.currency === currency && - r.status === 'pending', + r.status === "pending", ); if (duplicate) { @@ -72,7 +78,7 @@ export class WithdrawalQueueService { amount, currency, priority, - status: 'pending', + status: "pending", queuePosition: 0, retryCount: 0, createdAt: new Date(), @@ -81,38 +87,45 @@ export class WithdrawalQueueService { this.queue.push(request); this.sortQueue(); - this.logger.log(`Enqueued withdrawal ${request.id} for user ${userId} (${priority} priority)`); + this.logger.log( + `Enqueued withdrawal ${request.id} for user ${userId} (${priority} priority)`, + ); return { ...request }; } async processBatch(batchSize = 10): Promise { const pending = this.queue - .filter((r) => r.status === 'pending') + .filter((r) => r.status === "pending") .slice(0, batchSize); const results: WithdrawalRequest[] = []; for (const request of pending) { - request.status = 'processing'; + request.status = "processing"; try { await this.executeWithdrawal(request); - request.status = 'completed'; + request.status = "completed"; request.processedAt = new Date(); this.logger.log(`Withdrawal ${request.id} completed`); } catch (err) { request.retryCount += 1; if (request.retryCount >= MAX_RETRIES) { - request.status = 'failed'; - request.failureReason = err instanceof Error ? err.message : String(err); - this.logger.error(`Withdrawal ${request.id} failed after ${MAX_RETRIES} retries`); + request.status = "failed"; + request.failureReason = + err instanceof Error ? err.message : String(err); + this.logger.error( + `Withdrawal ${request.id} failed after ${MAX_RETRIES} retries`, + ); } else { - request.status = 'pending'; - this.logger.warn(`Withdrawal ${request.id} retrying (attempt ${request.retryCount})`); + request.status = "pending"; + this.logger.warn( + `Withdrawal ${request.id} retrying (attempt ${request.retryCount})`, + ); } } - if (request.status === 'completed' || request.status === 'failed') { + if (request.status === "completed" || request.status === "failed") { const idx = this.queue.indexOf(request); if (idx !== -1) this.queue.splice(idx, 1); this.processed.push(request); @@ -145,10 +158,10 @@ export class WithdrawalQueueService { return { queueLength: this.queue.length, - pending: byStatus('pending'), - processing: byStatus('processing'), - completed: byStatus('completed'), - failed: byStatus('failed'), + pending: byStatus("pending"), + processing: byStatus("processing"), + completed: byStatus("completed"), + failed: byStatus("failed"), }; } @@ -167,7 +180,7 @@ export class WithdrawalQueueService { // Placeholder for real withdrawal execution logic private async executeWithdrawal(request: WithdrawalRequest): Promise { if (!request.userId || !request.amount) { - throw new Error('Invalid withdrawal parameters'); + throw new Error("Invalid withdrawal parameters"); } } } diff --git a/apps/api-service/src/typeorm.d.ts b/apps/api-service/src/typeorm.d.ts index 79edd8c..c02128e 100644 --- a/apps/api-service/src/typeorm.d.ts +++ b/apps/api-service/src/typeorm.d.ts @@ -1,4 +1,4 @@ -declare module 'typeorm' { +declare module "typeorm" { export class Repository { create(plainObject?: any): T; save(entity: any): Promise; @@ -13,15 +13,25 @@ declare module 'typeorm' { where(condition: string, parameters?: any): this; andWhere(condition: string, parameters?: any): this; orWhere(condition: string, parameters?: any): this; - orderBy(sort: string, order?: 'ASC' | 'DESC'): this; + orderBy(sort: string, order?: "ASC" | "DESC"): this; take(limit: number): this; skip(offset: number): this; groupBy(groupBy: string): this; addGroupBy(column: string): this; select(columns: string | string[], ...selection: string[]): this; addSelect(column: string, ...selection: string[]): this; - innerJoin(table: string, alias: string, condition?: string, parameters?: any): this; - leftJoin(table: string, alias: string, condition?: string, parameters?: this): this; + innerJoin( + table: string, + alias: string, + condition?: string, + parameters?: any, + ): this; + leftJoin( + table: string, + alias: string, + condition?: string, + parameters?: this, + ): this; limit(limit: number): this; offset(offset: number): this; getQuery(): string; @@ -45,19 +55,26 @@ declare module 'typeorm' { } export function Entity(name?: string): ClassDecorator; - export function PrimaryGeneratedColumn(type?: 'uuid' | 'increment'): PropertyDecorator; + export function PrimaryGeneratedColumn( + type?: "uuid" | "increment", + ): PropertyDecorator; export function Column(options?: any): PropertyDecorator; export function CreateDateColumn(options?: any): PropertyDecorator; - export function Index(columns?: string | string[], options?: any): ClassDecorator & PropertyDecorator; + export function Index( + columns?: string | string[], + options?: any, + ): ClassDecorator & PropertyDecorator; export function PrimaryColumn(options?: any): PropertyDecorator; export function EntityRepository(name?: string): ClassDecorator; - export function EntityRepository(entityClass?: new (...args: any[]) => any): ClassDecorator; - + export function EntityRepository( + entityClass?: new (...args: any[]) => any, + ): ClassDecorator; + export interface DeleteResult { raw: any; affected?: number; } - + export class DataSource { getRepository(target: any): Repository; } diff --git a/apps/api-service/src/utils/safe-math.util.ts b/apps/api-service/src/utils/safe-math.util.ts index c6dccf1..f062bca 100644 --- a/apps/api-service/src/utils/safe-math.util.ts +++ b/apps/api-service/src/utils/safe-math.util.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException } from "@nestjs/common"; const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); @@ -6,9 +6,11 @@ const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); /** * Validates that a value is a finite, non-NaN number. */ -function assertFinite(value: number, label = 'value'): void { +function assertFinite(value: number, label = "value"): void { if (!Number.isFinite(value)) { - throw new BadRequestException(`SafeMath: ${label} must be a finite number, got ${value}`); + throw new BadRequestException( + `SafeMath: ${label} must be a finite number, got ${value}`, + ); } } @@ -29,37 +31,37 @@ export class SafeMath { * Safe addition — throws on overflow. */ static add(a: number, b: number): number { - assertFinite(a, 'a'); - assertFinite(b, 'b'); - return assertBounds(BigInt(Math.trunc(a)) + BigInt(Math.trunc(b)), 'add'); + assertFinite(a, "a"); + assertFinite(b, "b"); + return assertBounds(BigInt(Math.trunc(a)) + BigInt(Math.trunc(b)), "add"); } /** * Safe subtraction — throws on underflow. */ static sub(a: number, b: number): number { - assertFinite(a, 'a'); - assertFinite(b, 'b'); - return assertBounds(BigInt(Math.trunc(a)) - BigInt(Math.trunc(b)), 'sub'); + assertFinite(a, "a"); + assertFinite(b, "b"); + return assertBounds(BigInt(Math.trunc(a)) - BigInt(Math.trunc(b)), "sub"); } /** * Safe multiplication — throws on overflow. */ static mul(a: number, b: number): number { - assertFinite(a, 'a'); - assertFinite(b, 'b'); - return assertBounds(BigInt(Math.trunc(a)) * BigInt(Math.trunc(b)), 'mul'); + assertFinite(a, "a"); + assertFinite(b, "b"); + return assertBounds(BigInt(Math.trunc(a)) * BigInt(Math.trunc(b)), "mul"); } /** * Safe division — throws on division by zero. */ static div(a: number, b: number): number { - assertFinite(a, 'a'); - assertFinite(b, 'b'); + assertFinite(a, "a"); + assertFinite(b, "b"); if (b === 0) { - throw new BadRequestException('SafeMath: division by zero'); + throw new BadRequestException("SafeMath: division by zero"); } return a / b; } @@ -68,8 +70,8 @@ export class SafeMath { * Safe non-negative check — throws if value would go below zero. */ static subNonNegative(a: number, b: number): number { - assertFinite(a, 'a'); - assertFinite(b, 'b'); + assertFinite(a, "a"); + assertFinite(b, "b"); if (b > a) { throw new BadRequestException( `SafeMath: subtraction would underflow (${a} - ${b} < 0)`, @@ -82,8 +84,8 @@ export class SafeMath { * Safe percentage calculation — returns (value * pct) / 100. */ static percentage(value: number, pct: number): number { - assertFinite(value, 'value'); - assertFinite(pct, 'pct'); + assertFinite(value, "value"); + assertFinite(pct, "pct"); if (pct < 0 || pct > 100) { throw new BadRequestException( `SafeMath: percentage must be between 0 and 100, got ${pct}`, @@ -96,8 +98,8 @@ export class SafeMath { * Safe fee calculation — value * feeRate where feeRate is 0–1. */ static applyFee(value: number, feeRate: number): number { - assertFinite(value, 'value'); - assertFinite(feeRate, 'feeRate'); + assertFinite(value, "value"); + assertFinite(feeRate, "feeRate"); if (feeRate < 0 || feeRate > 1) { throw new BadRequestException( `SafeMath: feeRate must be between 0 and 1, got ${feeRate}`, diff --git a/apps/api-service/test/e2e/basic-api.e2e-spec.ts b/apps/api-service/test/e2e/basic-api.e2e-spec.ts index 98c4e80..2859125 100644 --- a/apps/api-service/test/e2e/basic-api.e2e-spec.ts +++ b/apps/api-service/test/e2e/basic-api.e2e-spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { HealthModule } from '../../src/health/health.module'; -import { ScannerModule } from '../../src/scanner/scanner.module'; -import { RulesModule } from '../../src/rules/rules.module'; - -describe('Basic API E2E Tests', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { HealthModule } from "../../src/health/health.module"; +import { ScannerModule } from "../../src/scanner/scanner.module"; +import { RulesModule } from "../../src/rules/rules.module"; + +describe("Basic API E2E Tests", () => { let app: INestApplication; beforeAll(async () => { @@ -23,35 +23,35 @@ describe('Basic API E2E Tests', () => { } }); - it('should return health check status', async () => { + it("should return health check status", async () => { const response = await request(app.getHttpServer()) - .get('/health') + .get("/health") .expect(200); - expect(response.body).toHaveProperty('status'); - expect(response.body).toHaveProperty('service'); - expect(response.body.status).toBe('healthy'); + expect(response.body).toHaveProperty("status"); + expect(response.body).toHaveProperty("service"); + expect(response.body.status).toBe("healthy"); }); - it('should return health readiness', async () => { + it("should return health readiness", async () => { const response = await request(app.getHttpServer()) - .get('/health/ready') + .get("/health/ready") .expect(200); - expect(response.body).toHaveProperty('status', 'healthy'); + expect(response.body).toHaveProperty("status", "healthy"); }); - it('should return health liveness', async () => { + it("should return health liveness", async () => { const response = await request(app.getHttpServer()) - .get('/health/live') + .get("/health/live") .expect(200); - expect(response.body).toHaveProperty('status', 'healthy'); + expect(response.body).toHaveProperty("status", "healthy"); }); - it('should scan code successfully', async () => { + it("should scan code successfully", async () => { const response = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ code: ` use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; @@ -76,41 +76,41 @@ describe('Basic API E2E Tests', () => { } } `, - source: 'test-contract.rs' + source: "test-contract.rs", }) .expect(200); - expect(response.body).toHaveProperty('scanTime'); - expect(response.body).toHaveProperty('violations'); - expect(response.body).toHaveProperty('hasViolations'); + expect(response.body).toHaveProperty("scanTime"); + expect(response.body).toHaveProperty("violations"); + expect(response.body).toHaveProperty("hasViolations"); }); - it('should get all rules', async () => { + it("should get all rules", async () => { const response = await request(app.getHttpServer()) - .get('/rules') + .get("/rules") .expect(200); expect(Array.isArray(response.body)).toBeTruthy(); expect(response.body.length).toBeGreaterThan(0); }); - it('should handle 404 for non-existent routes', async () => { + it("should handle 404 for non-existent routes", async () => { const response = await request(app.getHttpServer()) - .get('/non-existent-endpoint') + .get("/non-existent-endpoint") .expect(404); expect(response.body).toBeDefined(); }); - it('should handle empty code gracefully', async () => { + it("should handle empty code gracefully", async () => { const response = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ - code: '', - source: 'empty-test' + code: "", + source: "empty-test", }) .expect(200); - expect(response.body).toHaveProperty('violations'); + expect(response.body).toHaveProperty("violations"); }); -}); \ No newline at end of file +}); diff --git a/apps/api-service/test/e2e/e2e-test.module.ts b/apps/api-service/test/e2e/e2e-test.module.ts index 4965639..9c6371b 100644 --- a/apps/api-service/test/e2e/e2e-test.module.ts +++ b/apps/api-service/test/e2e/e2e-test.module.ts @@ -1,15 +1,15 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthModule } from '../../src/health/health.module'; -import { ScannerModule } from '../../src/scanner/scanner.module'; -import { AnalyzerModule } from '../../src/analyzer/analyzer.module'; -import { RulesModule } from '../../src/rules/rules.module'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { HealthModule } from "../../src/health/health.module"; +import { ScannerModule } from "../../src/scanner/scanner.module"; +import { AnalyzerModule } from "../../src/analyzer/analyzer.module"; +import { RulesModule } from "../../src/rules/rules.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - envFilePath: '.env.test', + envFilePath: ".env.test", }), HealthModule, ScannerModule, @@ -17,4 +17,4 @@ import { RulesModule } from '../../src/rules/rules.module'; RulesModule, ], }) -export class E2ETestModule {} \ No newline at end of file +export class E2ETestModule {} diff --git a/apps/api-service/test/e2e/failure-scenarios.e2e-spec.ts b/apps/api-service/test/e2e/failure-scenarios.e2e-spec.ts index 2a1505b..a886057 100644 --- a/apps/api-service/test/e2e/failure-scenarios.e2e-spec.ts +++ b/apps/api-service/test/e2e/failure-scenarios.e2e-spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { E2ETestModule } from './e2e-test.module'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { E2ETestModule } from "./e2e-test.module"; -describe('Failure Scenarios E2E Tests', () => { +describe("Failure Scenarios E2E Tests", () => { let app: INestApplication; beforeAll(async () => { @@ -19,55 +19,55 @@ describe('Failure Scenarios E2E Tests', () => { await app.close(); }); - it('should handle invalid transaction data gracefully', async () => { + it("should handle invalid transaction data gracefully", async () => { const response = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ - code: '', // Empty code - source: '' // Empty source + code: "", // Empty code + source: "", // Empty source }) .expect(200); // Should still return 200 but with validation info expect(response.body).toBeDefined(); }); - it('should handle malformed requests', async () => { + it("should handle malformed requests", async () => { const response = await request(app.getHttpServer()) - .post('/scanner/scan') - .send('invalid json') + .post("/scanner/scan") + .send("invalid json") .expect(400); expect(response.body).toBeDefined(); }); - it('should handle non-existent endpoints', async () => { + it("should handle non-existent endpoints", async () => { const response = await request(app.getHttpServer()) - .get('/non-existent-endpoint') + .get("/non-existent-endpoint") .expect(404); expect(response.body).toBeDefined(); }); - it('should handle timeout scenarios', async () => { + it("should handle timeout scenarios", async () => { // Test with timeout const response = await request(app.getHttpServer()) - .get('/health') + .get("/health") .timeout(5000) .expect(200); - expect(response.body).toHaveProperty('status'); + expect(response.body).toHaveProperty("status"); }); - it('should maintain service availability under stress', async () => { + it("should maintain service availability under stress", async () => { const stressRequests = 20; - const requests = Array(stressRequests).fill(null).map(() => - request(app.getHttpServer()).get('/health') - ); + const requests = Array(stressRequests) + .fill(null) + .map(() => request(app.getHttpServer()).get("/health")); const responses = await Promise.all(requests); - const successful = responses.filter(r => r.status === 200); - + const successful = responses.filter((r) => r.status === 200); + // Most requests should succeed expect(successful.length).toBeGreaterThan(stressRequests * 0.8); }); -}); \ No newline at end of file +}); diff --git a/apps/api-service/test/e2e/full-flow.e2e-spec.ts b/apps/api-service/test/e2e/full-flow.e2e-spec.ts index e2ee78f..649dabe 100644 --- a/apps/api-service/test/e2e/full-flow.e2e-spec.ts +++ b/apps/api-service/test/e2e/full-flow.e2e-spec.ts @@ -1,10 +1,9 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/core"; +import request from "supertest"; +import { E2ETestModule } from "./e2e-test.module"; -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/core'; -import request from 'supertest'; -import { E2ETestModule } from './e2e-test.module'; - -describe('Full Flow E2E Tests', () => { +describe("Full Flow E2E Tests", () => { let app: INestApplication; beforeAll(async () => { @@ -20,31 +19,31 @@ describe('Full Flow E2E Tests', () => { await app.close(); }); - it('should run a full scan, estimate, and tier simulation flow', async () => { + it("should run a full scan, estimate, and tier simulation flow", async () => { // 1. Scan code const scanResponse = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ code: `use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; #[contracttype] pub struct TestContract { pub owner: Address, pub counter: u64 } #[contractimpl] impl TestContract { pub fn new(owner: Address) -> Self { Self { owner, counter: 0 } } }`, - source: 'test-contract.rs', + source: "test-contract.rs", }) .expect(200); expect(scanResponse.body).toBeDefined(); - expect(scanResponse.body).toHaveProperty('scanTime'); + expect(scanResponse.body).toHaveProperty("scanTime"); // 2. Get tiered estimate const estimateResponse = await request(app.getHttpServer()) - .post('/tiered-pricing/estimate') + .post("/tiered-pricing/estimate") .send({ - chainId: 'testnet', + chainId: "testnet", gasUnits: 100000, userUsage: { - userId: 'user1', - currentTier: 'starter', + userId: "user1", + currentTier: "starter", currentMonthRequests: 10, monthlyUsage: [], averageRequestsPerMonth: 10, @@ -56,15 +55,15 @@ describe('Full Flow E2E Tests', () => { }) .expect(200); expect(estimateResponse.body).toBeDefined(); - expect(estimateResponse.body.data).toHaveProperty('finalPricePerRequest'); + expect(estimateResponse.body.data).toHaveProperty("finalPricePerRequest"); // 3. Simulate upgrade const simulateResponse = await request(app.getHttpServer()) - .post('/tiered-pricing/simulate-upgrade') + .post("/tiered-pricing/simulate-upgrade") .send({ userUsage: { - userId: 'user1', - currentTier: 'starter', + userId: "user1", + currentTier: "starter", currentMonthRequests: 10, monthlyUsage: [], averageRequestsPerMonth: 10, @@ -73,11 +72,11 @@ describe('Full Flow E2E Tests', () => { billingPeriodStart: new Date(), billingPeriodEnd: new Date(), }, - targetTier: 'developer', + targetTier: "developer", }) .expect(201); expect(simulateResponse.body).toBeDefined(); - expect(simulateResponse.body.data).toHaveProperty('fromTier', 'starter'); - expect(simulateResponse.body.data).toHaveProperty('toTier', 'developer'); + expect(simulateResponse.body.data).toHaveProperty("fromTier", "starter"); + expect(simulateResponse.body.data).toHaveProperty("toTier", "developer"); }); }); diff --git a/apps/api-service/test/e2e/gasless-transaction.e2e-spec.ts b/apps/api-service/test/e2e/gasless-transaction.e2e-spec.ts index 279877d..0a2a0f3 100644 --- a/apps/api-service/test/e2e/gasless-transaction.e2e-spec.ts +++ b/apps/api-service/test/e2e/gasless-transaction.e2e-spec.ts @@ -1,15 +1,15 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { E2ETestModule } from './e2e-test.module'; -import { ethers } from 'ethers'; -import { spawn } from 'child_process'; -import { promisify } from 'util'; -import { exec } from 'child_process'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { E2ETestModule } from "./e2e-test.module"; +import { ethers } from "ethers"; +import { spawn } from "child_process"; +import { promisify } from "util"; +import { exec } from "child_process"; const execPromise = promisify(exec); -describe('Gasless Transaction E2E Tests', () => { +describe("Gasless Transaction E2E Tests", () => { let app: INestApplication; let hardhatProcess: any; let provider: ethers.JsonRpcProvider; @@ -17,19 +17,19 @@ describe('Gasless Transaction E2E Tests', () => { beforeAll(async () => { // Start Hardhat node - hardhatProcess = spawn('npx', ['hardhat', 'node'], { + hardhatProcess = spawn("npx", ["hardhat", "node"], { cwd: process.cwd(), - stdio: 'pipe' + stdio: "pipe", }); // Wait for Hardhat to start - await new Promise(resolve => setTimeout(resolve, 3000)); + await new Promise((resolve) => setTimeout(resolve, 3000)); // Connect to Hardhat network - provider = new ethers.JsonRpcProvider('http://127.0.0.1:8545'); + provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545"); signer = new ethers.Wallet( - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', // First Hardhat account - provider + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", // First Hardhat account + provider, ); // Create NestJS app @@ -43,18 +43,18 @@ describe('Gasless Transaction E2E Tests', () => { afterAll(async () => { await app.close(); - + // Kill Hardhat process if (hardhatProcess) { hardhatProcess.kill(); } }); - describe('Gasless Transaction Flow', () => { - it('should create and execute a gasless transaction successfully', async () => { + describe("Gasless Transaction Flow", () => { + it("should create and execute a gasless transaction successfully", async () => { // Step 1: Create transaction via API const createResponse = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ code: ` use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; @@ -75,60 +75,61 @@ describe('Gasless Transaction E2E Tests', () => { } } `, - source: 'test-contract.rs' + source: "test-contract.rs", }) .expect(200); - expect(createResponse.body).toHaveProperty('violations'); - expect(createResponse.body).toHaveProperty('scanTime'); + expect(createResponse.body).toHaveProperty("violations"); + expect(createResponse.body).toHaveProperty("scanTime"); // Step 2: Test transaction processing const transactionData = { - merchantId: 'test-merchant-123', - to: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', - value: '1000000000000000000', // 1 ETH - data: '0x', - gasLimit: '21000' + merchantId: "test-merchant-123", + to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + value: "1000000000000000000", // 1 ETH + data: "0x", + gasLimit: "21000", }; // Mock transaction creation (since we don't have actual relayer yet) - const mockTxHash = ethers.keccak256(ethers.toUtf8Bytes('mock-transaction')); - + const mockTxHash = ethers.keccak256( + ethers.toUtf8Bytes("mock-transaction"), + ); + // Step 3: Verify transaction processing const verifyResponse = await request(app.getHttpServer()) - .get('/health') + .get("/health") .expect(200); - expect(verifyResponse.body).toHaveProperty('status', 'healthy'); - expect(verifyResponse.body).toHaveProperty('service', 'gasguard-api'); + expect(verifyResponse.body).toHaveProperty("status", "healthy"); + expect(verifyResponse.body).toHaveProperty("service", "gasguard-api"); }); - it('should handle transaction validation and error cases', async () => { + it("should handle transaction validation and error cases", async () => { // Test invalid transaction data const invalidResponse = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ - code: 'invalid code', - source: 'invalid-source' + code: "invalid code", + source: "invalid-source", }) .expect(200); // Should still return 200 but with validation errors // Test rate limiting - const promises = Array(15).fill(null).map(() => - request(app.getHttpServer()) - .get('/health') - ); + const promises = Array(15) + .fill(null) + .map(() => request(app.getHttpServer()).get("/health")); const responses = await Promise.allSettled(promises); const rateLimited = responses.filter( - (r: any) => r.status === 'fulfilled' && r.value.status === 429 + (r: any) => r.status === "fulfilled" && r.value.status === 429, ); // Should have some rate limited requests expect(rateLimited.length).toBeGreaterThan(0); }); - it('should process batch transactions efficiently', async () => { + it("should process batch transactions efficiently", async () => { const batchRequests = [ { code: ` @@ -140,7 +141,7 @@ describe('Gasless Transaction E2E Tests', () => { pub fn test() {} } `, - source: 'batch-test-1.rs' + source: "batch-test-1.rs", }, { code: ` @@ -152,14 +153,14 @@ describe('Gasless Transaction E2E Tests', () => { pub fn test() {} } `, - source: 'batch-test-2.rs' - } + source: "batch-test-2.rs", + }, ]; const startTime = Date.now(); - + const batchResponse = await request(app.getHttpServer()) - .post('/scanner/scan-batch') + .post("/scanner/scan-batch") .send(batchRequests) .expect(200); @@ -171,45 +172,45 @@ describe('Gasless Transaction E2E Tests', () => { }); }); - describe('Analytics and Monitoring', () => { - it('should provide analytics dashboard data', async () => { + describe("Analytics and Monitoring", () => { + it("should provide analytics dashboard data", async () => { const analyticsResponse = await request(app.getHttpServer()) - .get('/analytics/dashboard?timeRange=24h') + .get("/analytics/dashboard?timeRange=24h") .expect(200); - expect(analyticsResponse.body).toHaveProperty('timeRange', '24h'); - expect(analyticsResponse.body).toHaveProperty('transactionMetrics'); - expect(analyticsResponse.body).toHaveProperty('updatedAt'); + expect(analyticsResponse.body).toHaveProperty("timeRange", "24h"); + expect(analyticsResponse.body).toHaveProperty("transactionMetrics"); + expect(analyticsResponse.body).toHaveProperty("updatedAt"); }); - it('should track merchant-specific analytics', async () => { - const merchantId = 'test-merchant-456'; - + it("should track merchant-specific analytics", async () => { + const merchantId = "test-merchant-456"; + const merchantResponse = await request(app.getHttpServer()) .get(`/analytics/merchants/${merchantId}?timeRange=7d`) .expect(200); - expect(merchantResponse.body).toHaveProperty('merchantId', merchantId); - expect(merchantResponse.body).toHaveProperty('timeRange', '7d'); + expect(merchantResponse.body).toHaveProperty("merchantId", merchantId); + expect(merchantResponse.body).toHaveProperty("timeRange", "7d"); }); }); - describe('Failure Scenarios', () => { - it('should handle RPC timeout gracefully', async () => { + describe("Failure Scenarios", () => { + it("should handle RPC timeout gracefully", async () => { // This would test actual RPC timeout scenarios // For now, we test the error handling structure const errorResponse = await request(app.getHttpServer()) - .get('/health') - .set('X-Test-Error', 'timeout') + .get("/health") + .set("X-Test-Error", "timeout") .expect(200); // Should handle gracefully - expect(errorResponse.body).toHaveProperty('status'); + expect(errorResponse.body).toHaveProperty("status"); }); - it('should handle worker retry logic', async () => { + it("should handle worker retry logic", async () => { // Simulate failed transaction that should be retried const retryResponse = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ code: ` use soroban_sdk::{contract, contractimpl}; @@ -220,42 +221,44 @@ describe('Gasless Transaction E2E Tests', () => { pub fn test() { panic!("simulated failure"); } } `, - source: 'retry-test.rs' + source: "retry-test.rs", }) .expect(200); // Should still return successful response with error information - expect(retryResponse.body).toHaveProperty('violations'); + expect(retryResponse.body).toHaveProperty("violations"); }); - it('should handle signature expiration', async () => { + it("should handle signature expiration", async () => { const expiredSignature = { - signature: '0xexpired', - timestamp: Date.now() - 3600000 // 1 hour ago + signature: "0xexpired", + timestamp: Date.now() - 3600000, // 1 hour ago }; // Test signature validation const validationResponse = await request(app.getHttpServer()) - .post('/scanner/scan') - .set('Authorization', `Bearer ${expiredSignature.signature}`) + .post("/scanner/scan") + .set("Authorization", `Bearer ${expiredSignature.signature}`) .send({ - code: 'test code', - source: 'test.rs' + code: "test code", + source: "test.rs", }) .expect(200); // Should handle expired signatures gracefully - expect(validationResponse.body).toHaveProperty('violations'); + expect(validationResponse.body).toHaveProperty("violations"); }); }); - describe('Performance and Load Testing', () => { - it('should handle concurrent requests efficiently', async () => { + describe("Performance and Load Testing", () => { + it("should handle concurrent requests efficiently", async () => { const concurrentRequests = 10; - const requests = Array(concurrentRequests).fill(null).map((_, i) => - request(app.getHttpServer()) - .post('/scanner/scan') - .send({ - code: ` + const requests = Array(concurrentRequests) + .fill(null) + .map((_, i) => + request(app.getHttpServer()) + .post("/scanner/scan") + .send({ + code: ` use soroban_sdk::{contract, contractimpl}; #[contract] pub struct LoadTest${i}; @@ -264,46 +267,46 @@ describe('Gasless Transaction E2E Tests', () => { pub fn test() {} } `, - source: `load-test-${i}.rs` - }) - ); + source: `load-test-${i}.rs`, + }), + ); const startTime = Date.now(); const responses = await Promise.all(requests); const endTime = Date.now(); const processingTime = endTime - startTime; - + // All requests should succeed - responses.forEach(response => { + responses.forEach((response) => { expect(response.status).toBe(200); - expect(response.body).toHaveProperty('violations'); + expect(response.body).toHaveProperty("violations"); }); // Should process within reasonable time expect(processingTime).toBeLessThan(10000); }); - it('should maintain performance under load', async () => { + it("should maintain performance under load", async () => { // Test sustained load const sustainedRequests = 50; let successfulRequests = 0; - + for (let i = 0; i < sustainedRequests; i++) { try { const response = await request(app.getHttpServer()) - .get('/health') + .get("/health") .timeout(5000); - + if (response.status === 200) { successfulRequests++; } } catch (error) { // Request failed, continue } - + // Small delay to prevent overwhelming - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } // Should have high success rate @@ -311,4 +314,4 @@ describe('Gasless Transaction E2E Tests', () => { expect(successRate).toBeGreaterThan(80); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api-service/test/e2e/simple-transaction.e2e-spec.ts b/apps/api-service/test/e2e/simple-transaction.e2e-spec.ts index 32f5e63..5156619 100644 --- a/apps/api-service/test/e2e/simple-transaction.e2e-spec.ts +++ b/apps/api-service/test/e2e/simple-transaction.e2e-spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { E2ETestModule } from './e2e-test.module'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { E2ETestModule } from "./e2e-test.module"; -describe('Simple Transaction E2E Tests', () => { +describe("Simple Transaction E2E Tests", () => { let app: INestApplication; beforeAll(async () => { @@ -19,63 +19,63 @@ describe('Simple Transaction E2E Tests', () => { await app.close(); }); - it('should create and process a transaction successfully', async () => { + it("should create and process a transaction successfully", async () => { // Test transaction creation const response = await request(app.getHttpServer()) - .post('/scanner/scan') + .post("/scanner/scan") .send({ - code: 'test code content', - source: 'test-file.rs' + code: "test code content", + source: "test-file.rs", }) .expect(200); expect(response.body).toBeDefined(); - expect(response.body).toHaveProperty('scanTime'); + expect(response.body).toHaveProperty("scanTime"); }); - it('should handle health check endpoint', async () => { + it("should handle health check endpoint", async () => { const response = await request(app.getHttpServer()) - .get('/health') + .get("/health") .expect(200); - expect(response.body).toHaveProperty('status', 'healthy'); - expect(response.body).toHaveProperty('service', 'gasguard-api'); + expect(response.body).toHaveProperty("status", "healthy"); + expect(response.body).toHaveProperty("service", "gasguard-api"); }); - it('should handle analytics dashboard', async () => { + it("should handle analytics dashboard", async () => { const response = await request(app.getHttpServer()) - .get('/analytics/dashboard') + .get("/analytics/dashboard") .expect(200); - expect(response.body).toHaveProperty('timeRange'); - expect(response.body).toHaveProperty('updatedAt'); + expect(response.body).toHaveProperty("timeRange"); + expect(response.body).toHaveProperty("updatedAt"); }); - it('should handle rate limiting', async () => { + it("should handle rate limiting", async () => { // Make multiple rapid requests to test rate limiting - const requests = Array(15).fill(null).map(() => - request(app.getHttpServer()).get('/health') - ); + const requests = Array(15) + .fill(null) + .map(() => request(app.getHttpServer()).get("/health")); const responses = await Promise.all(requests); - + // Some requests should be rate limited (429) - const rateLimited = responses.filter(r => r.status === 429); + const rateLimited = responses.filter((r) => r.status === 429); expect(rateLimited.length).toBeGreaterThan(0); }); - it('should handle batch processing', async () => { + it("should handle batch processing", async () => { const batchData = [ - { code: 'code1', source: 'file1.rs' }, - { code: 'code2', source: 'file2.rs' } + { code: "code1", source: "file1.rs" }, + { code: "code2", source: "file2.rs" }, ]; const response = await request(app.getHttpServer()) - .post('/scanner/scan-batch') + .post("/scanner/scan-batch") .send(batchData) .expect(200); expect(Array.isArray(response.body)).toBeTruthy(); expect(response.body).toHaveLength(2); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/__tests__/analysis-validator.spec.ts b/apps/api/src/__tests__/analysis-validator.spec.ts index f91f491..168e24b 100644 --- a/apps/api/src/__tests__/analysis-validator.spec.ts +++ b/apps/api/src/__tests__/analysis-validator.spec.ts @@ -1,7 +1,7 @@ -import { Request, Response } from 'express'; -import { AnalysisValidator } from '../validation/analysis.validator'; +import { Request, Response } from "express"; +import { AnalysisValidator } from "../validation/analysis.validator"; -describe('AnalysisValidator', () => { +describe("AnalysisValidator", () => { let mockRequest: Partial; let mockResponse: Partial; let mockNext: jest.Mock; @@ -9,96 +9,102 @@ describe('AnalysisValidator', () => { beforeEach(() => { mockRequest = { body: {}, - headers: { 'x-request-id': 'test-request-id' } + headers: { "x-request-id": "test-request-id" }, }; mockResponse = { status: jest.fn().mockReturnThis(), - json: jest.fn() + json: jest.fn(), }; mockNext = jest.fn(); }); - describe('validateSubmission', () => { - it('should pass validation for valid request', () => { + describe("validateSubmission", () => { + it("should pass validation for valid request", () => { mockRequest.body = { project: { - name: 'Test Project', - description: 'A test project', - repositoryUrl: 'https://github.com/user/repo' + name: "Test Project", + description: "A test project", + repositoryUrl: "https://github.com/user/repo", }, - files: [{ - path: 'src/main.rs', - content: 'fn main() {}', - language: 'rust', - size: 100 - }], + files: [ + { + path: "src/main.rs", + content: "fn main() {}", + language: "rust", + size: 100, + }, + ], options: { - scanType: 'security', - severity: 'high' - } + scanType: "security", + severity: "high", + }, }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockNext).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); }); - it('should reject request with missing project', () => { + it("should reject request with missing project", () => { mockRequest.body = { - files: [{ - path: 'src/main.rs', - content: 'fn main() {}', - language: 'rust', - size: 100 - }] + files: [ + { + path: "src/main.rs", + content: "fn main() {}", + language: "rust", + size: 100, + }, + ], }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ - code: 'VALIDATION_ERROR', - message: 'Request validation failed' + code: "VALIDATION_ERROR", + message: "Request validation failed", }), validationErrors: expect.arrayContaining([ expect.objectContaining({ - field: 'project', - message: 'Project information is required' - }) - ]) - }) + field: "project", + message: "Project information is required", + }), + ]), + }), ); }); - it('should reject request with invalid repository URL', () => { + it("should reject request with invalid repository URL", () => { mockRequest.body = { project: { - name: 'Test Project', - repositoryUrl: 'not-a-valid-url' + name: "Test Project", + repositoryUrl: "not-a-valid-url", }, - files: [{ - path: 'src/main.rs', - content: 'fn main() {}', - language: 'rust', - size: 100 - }] + files: [ + { + path: "src/main.rs", + content: "fn main() {}", + language: "rust", + size: 100, + }, + ], }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockResponse.status).toHaveBeenCalledWith(400); @@ -106,26 +112,26 @@ describe('AnalysisValidator', () => { expect.objectContaining({ validationErrors: expect.arrayContaining([ expect.objectContaining({ - field: 'project.repositoryUrl', - message: 'Invalid repository URL format' - }) - ]) - }) + field: "project.repositoryUrl", + message: "Invalid repository URL format", + }), + ]), + }), ); }); - it('should reject request with no files', () => { + it("should reject request with no files", () => { mockRequest.body = { project: { - name: 'Test Project' + name: "Test Project", }, - files: [] + files: [], }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockResponse.status).toHaveBeenCalledWith(400); @@ -133,31 +139,33 @@ describe('AnalysisValidator', () => { expect.objectContaining({ validationErrors: expect.arrayContaining([ expect.objectContaining({ - field: 'files', - message: 'At least one file must be submitted' - }) - ]) - }) + field: "files", + message: "At least one file must be submitted", + }), + ]), + }), ); }); - it('should reject request with invalid file language', () => { + it("should reject request with invalid file language", () => { mockRequest.body = { project: { - name: 'Test Project' + name: "Test Project", }, - files: [{ - path: 'src/main.py', - content: 'print("hello")', - language: 'python', // not supported - size: 100 - }] + files: [ + { + path: "src/main.py", + content: 'print("hello")', + language: "python", // not supported + size: 100, + }, + ], }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockResponse.status).toHaveBeenCalledWith(400); @@ -165,31 +173,34 @@ describe('AnalysisValidator', () => { expect.objectContaining({ validationErrors: expect.arrayContaining([ expect.objectContaining({ - field: 'files[0].language', - message: 'Language must be one of: rust, typescript, javascript, solidity, soroban' - }) - ]) - }) + field: "files[0].language", + message: + "Language must be one of: rust, typescript, javascript, solidity, soroban", + }), + ]), + }), ); }); - it('should reject request with file too large', () => { + it("should reject request with file too large", () => { mockRequest.body = { project: { - name: 'Test Project' + name: "Test Project", }, - files: [{ - path: 'src/main.rs', - content: 'fn main() {}', - language: 'rust', - size: 20000000 // 20MB, over limit - }] + files: [ + { + path: "src/main.rs", + content: "fn main() {}", + language: "rust", + size: 20000000, // 20MB, over limit + }, + ], }; AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); expect(mockResponse.status).toHaveBeenCalledWith(400); @@ -197,12 +208,12 @@ describe('AnalysisValidator', () => { expect.objectContaining({ validationErrors: expect.arrayContaining([ expect.objectContaining({ - field: 'files[0].size', - message: 'File size exceeds maximum of 10485760 bytes' - }) - ]) - }) + field: "files[0].size", + message: "File size exceeds maximum of 10485760 bytes", + }), + ]), + }), ); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/__tests__/base-validator.spec.ts b/apps/api/src/__tests__/base-validator.spec.ts index bf1a478..3f44653 100644 --- a/apps/api/src/__tests__/base-validator.spec.ts +++ b/apps/api/src/__tests__/base-validator.spec.ts @@ -1,130 +1,203 @@ -import { BaseValidator } from '../validation/base.validator'; - -describe('BaseValidator', () => { - describe('isValidEthereumAddress', () => { - it('should validate correct Ethereum addresses', () => { - expect(BaseValidator.isValidEthereumAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44e')).toBe(true); - expect(BaseValidator.isValidEthereumAddress('0x0000000000000000000000000000000000000000')).toBe(true); - }); - - it('should reject invalid Ethereum addresses', () => { - expect(BaseValidator.isValidEthereumAddress('')).toBe(false); - expect(BaseValidator.isValidEthereumAddress('0x')).toBe(false); - expect(BaseValidator.isValidEthereumAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44')).toBe(false); // too short - expect(BaseValidator.isValidEthereumAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44ee')).toBe(false); // too long - expect(BaseValidator.isValidEthereumAddress('742d35Cc6634C0532925a3b844Bc454e4438f44e')).toBe(false); // missing 0x - expect(BaseValidator.isValidEthereumAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44g')).toBe(false); // invalid char +import { BaseValidator } from "../validation/base.validator"; + +describe("BaseValidator", () => { + describe("isValidEthereumAddress", () => { + it("should validate correct Ethereum addresses", () => { + expect( + BaseValidator.isValidEthereumAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + ), + ).toBe(true); + expect( + BaseValidator.isValidEthereumAddress( + "0x0000000000000000000000000000000000000000", + ), + ).toBe(true); + }); + + it("should reject invalid Ethereum addresses", () => { + expect(BaseValidator.isValidEthereumAddress("")).toBe(false); + expect(BaseValidator.isValidEthereumAddress("0x")).toBe(false); + expect( + BaseValidator.isValidEthereumAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44", + ), + ).toBe(false); // too short + expect( + BaseValidator.isValidEthereumAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44ee", + ), + ).toBe(false); // too long + expect( + BaseValidator.isValidEthereumAddress( + "742d35Cc6634C0532925a3b844Bc454e4438f44e", + ), + ).toBe(false); // missing 0x + expect( + BaseValidator.isValidEthereumAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44g", + ), + ).toBe(false); // invalid char }); }); - describe('isValidStellarAddress', () => { - it('should validate correct Stellar addresses', () => { - expect(BaseValidator.isValidStellarAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ')).toBe(true); - expect(BaseValidator.isValidStellarAddress('GC7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ')).toBe(true); - }); - - it('should reject invalid Stellar addresses', () => { - expect(BaseValidator.isValidStellarAddress('')).toBe(false); - expect(BaseValidator.isValidStellarAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSG')).toBe(false); // too short - expect(BaseValidator.isValidStellarAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZZ')).toBe(false); // too long - expect(BaseValidator.isValidStellarAddress('HA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ')).toBe(false); // wrong prefix + describe("isValidStellarAddress", () => { + it("should validate correct Stellar addresses", () => { + expect( + BaseValidator.isValidStellarAddress( + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + ), + ).toBe(true); + expect( + BaseValidator.isValidStellarAddress( + "GC7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + ), + ).toBe(true); + }); + + it("should reject invalid Stellar addresses", () => { + expect(BaseValidator.isValidStellarAddress("")).toBe(false); + expect( + BaseValidator.isValidStellarAddress( + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSG", + ), + ).toBe(false); // too short + expect( + BaseValidator.isValidStellarAddress( + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZZ", + ), + ).toBe(false); // too long + expect( + BaseValidator.isValidStellarAddress( + "HA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + ), + ).toBe(false); // wrong prefix }); }); - describe('isValidAddress', () => { - it('should validate Ethereum addresses for EVM chains', () => { - expect(BaseValidator.isValidAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44e', 1)).toBe(true); // Ethereum - expect(BaseValidator.isValidAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44e', 137)).toBe(true); // Polygon - }); - - it('should validate Stellar addresses for Stellar chain', () => { - expect(BaseValidator.isValidAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', 0)).toBe(true); - }); - - it('should default to Ethereum validation for unknown chains', () => { - expect(BaseValidator.isValidAddress('0x742d35Cc6634C0532925a3b844Bc454e4438f44e')).toBe(true); - expect(BaseValidator.isValidAddress('GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ')).toBe(false); + describe("isValidAddress", () => { + it("should validate Ethereum addresses for EVM chains", () => { + expect( + BaseValidator.isValidAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + 1, + ), + ).toBe(true); // Ethereum + expect( + BaseValidator.isValidAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + 137, + ), + ).toBe(true); // Polygon + }); + + it("should validate Stellar addresses for Stellar chain", () => { + expect( + BaseValidator.isValidAddress( + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + 0, + ), + ).toBe(true); + }); + + it("should default to Ethereum validation for unknown chains", () => { + expect( + BaseValidator.isValidAddress( + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + ), + ).toBe(true); + expect( + BaseValidator.isValidAddress( + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + ), + ).toBe(false); }); }); - describe('isValidGasLimit', () => { - it('should validate gas limits within range', () => { + describe("isValidGasLimit", () => { + it("should validate gas limits within range", () => { expect(BaseValidator.isValidGasLimit(21000)).toBe(true); expect(BaseValidator.isValidGasLimit(30000000)).toBe(true); - expect(BaseValidator.isValidGasLimit('21000')).toBe(true); + expect(BaseValidator.isValidGasLimit("21000")).toBe(true); }); - it('should reject gas limits outside range', () => { + it("should reject gas limits outside range", () => { expect(BaseValidator.isValidGasLimit(20000)).toBe(false); // too low expect(BaseValidator.isValidGasLimit(40000000)).toBe(false); // too high - expect(BaseValidator.isValidGasLimit('invalid')).toBe(false); + expect(BaseValidator.isValidGasLimit("invalid")).toBe(false); }); }); - describe('isValidGasPrice', () => { - it('should validate gas prices within range', () => { + describe("isValidGasPrice", () => { + it("should validate gas prices within range", () => { expect(BaseValidator.isValidGasPrice(1000000000)).toBe(true); // 1 gwei expect(BaseValidator.isValidGasPrice(1000000000000)).toBe(true); // 1000 gwei - expect(BaseValidator.isValidGasPrice('1000000000')).toBe(true); + expect(BaseValidator.isValidGasPrice("1000000000")).toBe(true); }); - it('should reject gas prices outside range', () => { + it("should reject gas prices outside range", () => { expect(BaseValidator.isValidGasPrice(500000000)).toBe(false); // too low expect(BaseValidator.isValidGasPrice(2000000000000)).toBe(false); // too high - expect(BaseValidator.isValidGasPrice('invalid')).toBe(false); + expect(BaseValidator.isValidGasPrice("invalid")).toBe(false); }); }); - describe('isValidChainId', () => { - it('should validate supported chain IDs', () => { + describe("isValidChainId", () => { + it("should validate supported chain IDs", () => { expect(BaseValidator.isValidChainId(1)).toBe(true); // Ethereum expect(BaseValidator.isValidChainId(137)).toBe(true); // Polygon expect(BaseValidator.isValidChainId(56)).toBe(true); // BSC }); - it('should reject unsupported chain IDs', () => { + it("should reject unsupported chain IDs", () => { expect(BaseValidator.isValidChainId(999)).toBe(false); expect(BaseValidator.isValidChainId(0)).toBe(false); }); }); - describe('isValidTransactionType', () => { - it('should validate supported transaction types', () => { - expect(BaseValidator.isValidTransactionType('transfer')).toBe(true); - expect(BaseValidator.isValidTransactionType('contract-call')).toBe(true); - expect(BaseValidator.isValidTransactionType('swap')).toBe(true); + describe("isValidTransactionType", () => { + it("should validate supported transaction types", () => { + expect(BaseValidator.isValidTransactionType("transfer")).toBe(true); + expect(BaseValidator.isValidTransactionType("contract-call")).toBe(true); + expect(BaseValidator.isValidTransactionType("swap")).toBe(true); }); - it('should reject unsupported transaction types', () => { - expect(BaseValidator.isValidTransactionType('invalid')).toBe(false); - expect(BaseValidator.isValidTransactionType('')).toBe(false); + it("should reject unsupported transaction types", () => { + expect(BaseValidator.isValidTransactionType("invalid")).toBe(false); + expect(BaseValidator.isValidTransactionType("")).toBe(false); }); }); - describe('isValidUrl', () => { - it('should validate correct URLs', () => { - expect(BaseValidator.isValidUrl('https://github.com/user/repo')).toBe(true); - expect(BaseValidator.isValidUrl('http://example.com')).toBe(true); - expect(BaseValidator.isValidUrl('git@github.com:user/repo.git')).toBe(true); + describe("isValidUrl", () => { + it("should validate correct URLs", () => { + expect(BaseValidator.isValidUrl("https://github.com/user/repo")).toBe( + true, + ); + expect(BaseValidator.isValidUrl("http://example.com")).toBe(true); + expect(BaseValidator.isValidUrl("git@github.com:user/repo.git")).toBe( + true, + ); }); - it('should reject invalid URLs', () => { - expect(BaseValidator.isValidUrl('')).toBe(false); - expect(BaseValidator.isValidUrl('not-a-url')).toBe(false); - expect(BaseValidator.isValidUrl('ftp://example.com')).toBe(false); + it("should reject invalid URLs", () => { + expect(BaseValidator.isValidUrl("")).toBe(false); + expect(BaseValidator.isValidUrl("not-a-url")).toBe(false); + expect(BaseValidator.isValidUrl("ftp://example.com")).toBe(false); }); }); - describe('isValidTimestamp', () => { - it('should validate ISO timestamps', () => { - expect(BaseValidator.isValidTimestamp('2024-01-01T00:00:00.000Z')).toBe(true); - expect(BaseValidator.isValidTimestamp('2024-01-01T00:00:00Z')).toBe(true); + describe("isValidTimestamp", () => { + it("should validate ISO timestamps", () => { + expect(BaseValidator.isValidTimestamp("2024-01-01T00:00:00.000Z")).toBe( + true, + ); + expect(BaseValidator.isValidTimestamp("2024-01-01T00:00:00Z")).toBe(true); }); - it('should reject invalid timestamps', () => { - expect(BaseValidator.isValidTimestamp('')).toBe(false); - expect(BaseValidator.isValidTimestamp('2024-01-01')).toBe(false); - expect(BaseValidator.isValidTimestamp('invalid')).toBe(false); + it("should reject invalid timestamps", () => { + expect(BaseValidator.isValidTimestamp("")).toBe(false); + expect(BaseValidator.isValidTimestamp("2024-01-01")).toBe(false); + expect(BaseValidator.isValidTimestamp("invalid")).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/__tests__/cross-chain-gas.service.spec.ts b/apps/api/src/__tests__/cross-chain-gas.service.spec.ts index f506357..fab31a4 100644 --- a/apps/api/src/__tests__/cross-chain-gas.service.spec.ts +++ b/apps/api/src/__tests__/cross-chain-gas.service.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CrossChainGasService } from '../services/cross-chain-gas.service'; -import { CrossChainGasRequest } from '../schemas/cross-chain-gas.schema'; +import { Test, TestingModule } from "@nestjs/testing"; +import { CrossChainGasService } from "../services/cross-chain-gas.service"; +import { CrossChainGasRequest } from "../schemas/cross-chain-gas.schema"; -describe('CrossChainGasService', () => { +describe("CrossChainGasService", () => { let service: CrossChainGasService; beforeEach(async () => { @@ -13,69 +13,69 @@ describe('CrossChainGasService', () => { service = module.get(CrossChainGasService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('getCrossChainGasComparison', () => { - it('should return comparison for transfer transaction', async () => { + describe("getCrossChainGasComparison", () => { + it("should return comparison for transfer transaction", async () => { const request: CrossChainGasRequest = { - txType: 'transfer' + txType: "transfer", }; const result = await service.getCrossChainGasComparison(request); - expect(result).toHaveProperty('txType', 'transfer'); - expect(result).toHaveProperty('timestamp'); - expect(result).toHaveProperty('chains'); + expect(result).toHaveProperty("txType", "transfer"); + expect(result).toHaveProperty("timestamp"); + expect(result).toHaveProperty("chains"); expect(result.chains).toBeInstanceOf(Array); expect(result.chains.length).toBeGreaterThan(0); }); - it('should return comparison for contract-call transaction', async () => { + it("should return comparison for contract-call transaction", async () => { const request: CrossChainGasRequest = { - txType: 'contract-call' + txType: "contract-call", }; const result = await service.getCrossChainGasComparison(request); - expect(result.txType).toBe('contract-call'); + expect(result.txType).toBe("contract-call"); expect(result.chains).toBeDefined(); }); - it('should return comparison for swap transaction', async () => { + it("should return comparison for swap transaction", async () => { const request: CrossChainGasRequest = { - txType: 'swap' + txType: "swap", }; const result = await service.getCrossChainGasComparison(request); - expect(result.txType).toBe('swap'); + expect(result.txType).toBe("swap"); expect(result.chains).toBeDefined(); }); - it('should rank chains by cost (lowest first)', async () => { + it("should rank chains by cost (lowest first)", async () => { const request: CrossChainGasRequest = { - txType: 'transfer' + txType: "transfer", }; const result = await service.getCrossChainGasComparison(request); - const costs = result.chains.map(chain => chain.estimatedCostUSD); + const costs = result.chains.map((chain) => chain.estimatedCostUSD); const sortedCosts = [...costs].sort((a, b) => a - b); - + expect(costs).toEqual(sortedCosts); expect(result.chains[0].rank).toBe(1); }); - it('should include all supported chains', async () => { + it("should include all supported chains", async () => { const request: CrossChainGasRequest = { - txType: 'transfer' + txType: "transfer", }; const result = await service.getCrossChainGasComparison(request); - const chainIds = result.chains.map(chain => chain.chainId); + const chainIds = result.chains.map((chain) => chain.chainId); expect(chainIds).toContain(1); // Ethereum expect(chainIds).toContain(137); // Polygon expect(chainIds).toContain(56); // BSC @@ -84,14 +84,14 @@ describe('CrossChainGasService', () => { }); }); - describe('getSupportedChains', () => { - it('should return all supported chains', async () => { + describe("getSupportedChains", () => { + it("should return all supported chains", async () => { const chains = await service.getSupportedChains(); expect(chains).toBeInstanceOf(Array); expect(chains.length).toBe(5); - const chainIds = chains.map(chain => chain.chainId); + const chainIds = chains.map((chain) => chain.chainId); expect(chainIds).toContain(1); expect(chainIds).toContain(137); expect(chainIds).toContain(56); @@ -99,28 +99,28 @@ describe('CrossChainGasService', () => { expect(chainIds).toContain(10); }); - it('should include required chain properties', async () => { + it("should include required chain properties", async () => { const chains = await service.getSupportedChains(); - chains.forEach(chain => { - expect(chain).toHaveProperty('chainId'); - expect(chain).toHaveProperty('chainName'); - expect(chain).toHaveProperty('nativeToken'); - expect(chain).toHaveProperty('rpcUrl'); - expect(chain).toHaveProperty('blockTime'); + chains.forEach((chain) => { + expect(chain).toHaveProperty("chainId"); + expect(chain).toHaveProperty("chainName"); + expect(chain).toHaveProperty("nativeToken"); + expect(chain).toHaveProperty("rpcUrl"); + expect(chain).toHaveProperty("blockTime"); }); }); }); - describe('gas cost normalization', () => { - it('should normalize costs correctly for different chains', async () => { + describe("gas cost normalization", () => { + it("should normalize costs correctly for different chains", async () => { const request: CrossChainGasRequest = { - txType: 'transfer' + txType: "transfer", }; const result = await service.getCrossChainGasComparison(request); - result.chains.forEach(chain => { + result.chains.forEach((chain) => { expect(chain.estimatedCostUSD).toBeGreaterThan(0); expect(chain.estimatedCostNative).toBeDefined(); expect(chain.averageConfirmationTime).toBeDefined(); @@ -128,18 +128,20 @@ describe('CrossChainGasService', () => { }); }); - it('should calculate USD costs correctly', async () => { + it("should calculate USD costs correctly", async () => { const request: CrossChainGasRequest = { - txType: 'transfer' + txType: "transfer", }; const result = await service.getCrossChainGasComparison(request); - const polygonChain = result.chains.find(chain => chain.chainId === 137); - const ethereumChain = result.chains.find(chain => chain.chainId === 1); + const polygonChain = result.chains.find((chain) => chain.chainId === 137); + const ethereumChain = result.chains.find((chain) => chain.chainId === 1); // Polygon should be cheaper than Ethereum for transfers - expect(polygonChain!.estimatedCostUSD).toBeLessThan(ethereumChain!.estimatedCostUSD); + expect(polygonChain!.estimatedCostUSD).toBeLessThan( + ethereumChain!.estimatedCostUSD, + ); }); }); }); diff --git a/apps/api/src/__tests__/failed-transaction.service.spec.ts b/apps/api/src/__tests__/failed-transaction.service.spec.ts index 8aea20c..702678d 100644 --- a/apps/api/src/__tests__/failed-transaction.service.spec.ts +++ b/apps/api/src/__tests__/failed-transaction.service.spec.ts @@ -1,8 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { FailedTransactionService } from '../services/failed-transaction.service'; -import { FailureCategory, FailedTransaction } from '../schemas/failed-transaction.schema'; - -describe('FailedTransactionService', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { FailedTransactionService } from "../services/failed-transaction.service"; +import { + FailureCategory, + FailedTransaction, +} from "../schemas/failed-transaction.schema"; + +describe("FailedTransactionService", () => { let service: FailedTransactionService; beforeEach(async () => { @@ -13,207 +16,211 @@ describe('FailedTransactionService', () => { service = module.get(FailedTransactionService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('trackFailedTransaction', () => { - it('should track a failed transaction with underpriced gas', async () => { + describe("trackFailedTransaction", () => { + it("should track a failed transaction with underpriced gas", async () => { const transactionData = { - hash: '0x1234567890abcdef1234567890abcdef12345678', - wallet: '0xabcdef1234567890abcdef1234567890abcdef12', + hash: "0x1234567890abcdef1234567890abcdef12345678", + wallet: "0xabcdef1234567890abcdef1234567890abcdef12", chainId: 1, - gasUsed: '21000', - gasPrice: '10000000000', // 10 gwei, below network price + gasUsed: "21000", + gasPrice: "10000000000", // 10 gwei, below network price metadata: { nonce: 1, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }; const result = await service.trackFailedTransaction(transactionData); - expect(result.failureCategory).toBe('underpriced_gas'); + expect(result.failureCategory).toBe("underpriced_gas"); expect(result.hash).toBe(transactionData.hash); expect(result.wallet).toBe(transactionData.wallet); }); - it('should track a failed transaction with out of gas', async () => { + it("should track a failed transaction with out of gas", async () => { const transactionData = { - hash: '0x123', - wallet: '0xabc', + hash: "0x123", + wallet: "0xabc", chainId: 1, - gasUsed: '21000', - gasPrice: '100000000000', // 100 gwei - very high to bypass underpriced check - gasLimit: '21000', + gasUsed: "21000", + gasPrice: "100000000000", // 100 gwei - very high to bypass underpriced check + gasLimit: "21000", status: 0, - revertReason: 'exceeded transaction gas', + revertReason: "exceeded transaction gas", timestamp: new Date().toISOString(), metadata: { nonce: 1, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }; const result = await service.trackFailedTransaction(transactionData); - expect(result.failureCategory).toBe('out_of_gas'); - expect(result.effectiveFee).toBe((BigInt(transactionData.gasUsed) * BigInt(transactionData.gasPrice)).toString()); + expect(result.failureCategory).toBe("out_of_gas"); + expect(result.effectiveFee).toBe( + ( + BigInt(transactionData.gasUsed) * BigInt(transactionData.gasPrice) + ).toString(), + ); }); - it('should track a failed transaction with insufficient balance', async () => { + it("should track a failed transaction with insufficient balance", async () => { const transactionData = { - hash: '0x456', - wallet: '0xdef', + hash: "0x456", + wallet: "0xdef", chainId: 1, - gasUsed: '150000', - gasPrice: '20000000000', - revertReason: 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT', + gasUsed: "150000", + gasPrice: "20000000000", + revertReason: "UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT", metadata: { nonce: 3, - gasLimit: '200000', - transactionType: 'legacy' as const - } + gasLimit: "200000", + transactionType: "legacy" as const, + }, }; const result = await service.trackFailedTransaction(transactionData); - expect(result.failureCategory).toBe('slippage_exceeded'); - expect(result.revertReason).toContain('INSUFFICIENT_OUTPUT_AMOUNT'); + expect(result.failureCategory).toBe("slippage_exceeded"); + expect(result.revertReason).toContain("INSUFFICIENT_OUTPUT_AMOUNT"); }); - it('should track a failed transaction with nonce conflict', async () => { - const wallet = '0xabcdef1234567890abcdef1234567890abcdef12'; + it("should track a failed transaction with nonce conflict", async () => { + const wallet = "0xabcdef1234567890abcdef1234567890abcdef12"; const nonce = 4; // First transaction await service.trackFailedTransaction({ - hash: '0x1234567890abcdef1234567890abcdef1234567b', + hash: "0x1234567890abcdef1234567890abcdef1234567b", wallet, chainId: 1, - gasUsed: '21000', - gasPrice: '20000000000', + gasUsed: "21000", + gasPrice: "20000000000", metadata: { nonce, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }); // Second transaction with same nonce (within 5 minutes) const result = await service.trackFailedTransaction({ - hash: '0x1234567890abcdef1234567890abcdef1234567c', + hash: "0x1234567890abcdef1234567890abcdef1234567c", wallet, chainId: 1, - gasUsed: '21000', - gasPrice: '20000000000', + gasUsed: "21000", + gasPrice: "20000000000", metadata: { nonce, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }); - expect(result.failureCategory).toBe('nonce_conflict'); + expect(result.failureCategory).toBe("nonce_conflict"); }); }); - describe('getWalletFailures', () => { - const wallet = '0xabcdef1234567890abcdef1234567890abcdef12'; + describe("getWalletFailures", () => { + const wallet = "0xabcdef1234567890abcdef1234567890abcdef12"; beforeEach(async () => { // Add some test transactions await service.trackFailedTransaction({ - hash: '0x1111111111111111111111111111111111111111', + hash: "0x1111111111111111111111111111111111111111", wallet, chainId: 1, - gasUsed: '21000', - gasPrice: '20000000000', + gasUsed: "21000", + gasPrice: "20000000000", metadata: { nonce: 1, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }); await service.trackFailedTransaction({ - hash: '0x2222222222222222222222222222222222222222', + hash: "0x2222222222222222222222222222222222222222", wallet, chainId: 137, - gasUsed: '50000', - gasPrice: '30000000000', + gasUsed: "50000", + gasPrice: "30000000000", metadata: { nonce: 2, - gasLimit: '100000', - transactionType: 'legacy' as const - } + gasLimit: "100000", + transactionType: "legacy" as const, + }, }); }); - it('should return all failures for a wallet', async () => { + it("should return all failures for a wallet", async () => { const failures = await service.getWalletFailures(wallet); expect(failures).toHaveLength(2); expect(failures[0].wallet).toBe(wallet); expect(failures[1].wallet).toBe(wallet); }); - it('should filter failures by chain ID', async () => { + it("should filter failures by chain ID", async () => { const failures = await service.getWalletFailures(wallet, [1]); expect(failures).toHaveLength(1); expect(failures[0].chainId).toBe(1); }); }); - describe('calculateCostMetrics', () => { - const wallet = '0xabcdef1234567890abcdef1234567890abcdef12'; + describe("calculateCostMetrics", () => { + const wallet = "0xabcdef1234567890abcdef1234567890abcdef12"; beforeEach(async () => { await service.trackFailedTransaction({ - hash: '0x3333333333333333333333333333333333333333', + hash: "0x3333333333333333333333333333333333333333", wallet, chainId: 1, - gasUsed: '21000', - gasPrice: '20000000000', + gasUsed: "21000", + gasPrice: "20000000000", metadata: { nonce: 1, - gasLimit: '21000', - transactionType: 'legacy' as const - } + gasLimit: "21000", + transactionType: "legacy" as const, + }, }); await service.trackFailedTransaction({ - hash: '0x4444444444444444444444444444444444444444', + hash: "0x4444444444444444444444444444444444444444", wallet, chainId: 1, - gasUsed: '150000', - gasPrice: '30000000000', + gasUsed: "150000", + gasPrice: "30000000000", metadata: { nonce: 2, - gasLimit: '200000', - transactionType: 'legacy' as const - } + gasLimit: "200000", + transactionType: "legacy" as const, + }, }); }); - it('should calculate total gas wasted correctly', async () => { + it("should calculate total gas wasted correctly", async () => { const metrics = await service.calculateCostMetrics(wallet); - - const expectedTotal = - (BigInt(21000) * BigInt(20000000000)) + - (BigInt(150000) * BigInt(30000000000)); - + + const expectedTotal = + BigInt(21000) * BigInt(20000000000) + + BigInt(150000) * BigInt(30000000000); + expect(metrics.totalGasWasted).toBe(expectedTotal.toString()); expect(metrics.totalGasWastedUSD).toBeGreaterThan(0); }); - it('should calculate average waste per failure', async () => { + it("should calculate average waste per failure", async () => { const metrics = await service.calculateCostMetrics(wallet); - + const totalWaste = BigInt(metrics.totalGasWasted); const expectedAverage = totalWaste / BigInt(2); - + expect(metrics.averageWastePerFailure).toBe(expectedAverage.toString()); }); }); diff --git a/apps/api/src/__tests__/fuzz-validators.spec.ts b/apps/api/src/__tests__/fuzz-validators.spec.ts index 6ae024c..f090633 100644 --- a/apps/api/src/__tests__/fuzz-validators.spec.ts +++ b/apps/api/src/__tests__/fuzz-validators.spec.ts @@ -1,8 +1,8 @@ -import { Request, Response } from 'express'; -import { BaseValidator } from '../validation/base.validator'; -import { AnalysisValidator } from '../validation/analysis.validator'; -import { CrossChainGasValidator } from '../validation/cross-chain-gas.validator'; -import { FailedTransactionValidator } from '../validation/failed-transaction.validator'; +import { Request, Response } from "express"; +import { BaseValidator } from "../validation/base.validator"; +import { AnalysisValidator } from "../validation/analysis.validator"; +import { CrossChainGasValidator } from "../validation/cross-chain-gas.validator"; +import { FailedTransactionValidator } from "../validation/failed-transaction.validator"; /** * Fuzz testing utilities for validators @@ -11,8 +11,9 @@ import { FailedTransactionValidator } from '../validation/failed-transaction.val // Random string generators const randomString = (length: number): string => { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'; - let result = ''; + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"; + let result = ""; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } @@ -20,8 +21,8 @@ const randomString = (length: number): string => { }; const randomHexString = (length: number): string => { - const chars = '0123456789abcdefABCDEF'; - let result = '0x'; + const chars = "0123456789abcdefABCDEF"; + let result = "0x"; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } @@ -45,9 +46,9 @@ const generateEthereumAddress = (valid: boolean): string => { }; const generateStellarAddress = (valid: boolean): string => { - const prefix = Math.random() > 0.5 ? 'G' : 'C'; - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - + const prefix = Math.random() > 0.5 ? "G" : "C"; + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + if (valid) { let result = prefix; for (let i = 0; i < 55; i++) { @@ -55,37 +56,50 @@ const generateStellarAddress = (valid: boolean): string => { } return result; } - + return prefix + randomString(randomNumber(1, 60)); }; // Generate random bytes for potential buffer overflow tests const generateLargeBuffer = (size: number): Buffer => { - return Buffer.alloc(size, 'a'); + return Buffer.alloc(size, "a"); }; // Generate random arrays -const generateRandomArray = (generator: () => T, minLength: number, maxLength: number): T[] => { +const generateRandomArray = ( + generator: () => T, + minLength: number, + maxLength: number, +): T[] => { const length = randomNumber(minLength, maxLength); return Array.from({ length }, generator); }; // Generate random object with fuzzed properties const generateFuzzedObject = (depth: number = 0): any => { - if (depth > 3) return 'max_depth_reached'; - + if (depth > 3) return "max_depth_reached"; + const type = randomNumber(0, 8); - + switch (type) { - case 0: return randomString(randomNumber(1, 50)); - case 1: return randomNumber(-1000, 1000); - case 2: return randomFloat(-1000, 1000); - case 3: return Math.random() > 0.5; - case 4: return null; - case 5: return undefined; - case 6: return generateFuzzedObject(depth + 1); - case 7: return generateRandomArray(() => generateFuzzedObject(depth + 1), 1, 10); - case 8: return { nested: generateFuzzedObject(depth + 1) }; + case 0: + return randomString(randomNumber(1, 50)); + case 1: + return randomNumber(-1000, 1000); + case 2: + return randomFloat(-1000, 1000); + case 3: + return Math.random() > 0.5; + case 4: + return null; + case 5: + return undefined; + case 6: + return generateFuzzedObject(depth + 1); + case 7: + return generateRandomArray(() => generateFuzzedObject(depth + 1), 1, 10); + case 8: + return { nested: generateFuzzedObject(depth + 1) }; } }; @@ -97,65 +111,89 @@ let errorTests = 0; /** * BaseValidator Fuzz Tests */ -describe('BaseValidator Fuzz Tests', () => { - describe('isValidEthereumAddress fuzz testing', () => { - it('should handle various input types without crashing', () => { +describe("BaseValidator Fuzz Tests", () => { + describe("isValidEthereumAddress fuzz testing", () => { + it("should handle various input types without crashing", () => { const inputs: any[] = [ - null, undefined, '', '0x', randomString(10), randomHexString(10), - generateEthereumAddress(true), generateEthereumAddress(false), - 123, {}, [], { address: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e' }, - '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'.repeat(10), // Very long - '\x00\x01\x02', // Binary data - '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', // Max uint160 - '-1', '0x-1', NaN, Infinity, -Infinity + null, + undefined, + "", + "0x", + randomString(10), + randomHexString(10), + generateEthereumAddress(true), + generateEthereumAddress(false), + 123, + {}, + [], + { address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" }, + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".repeat(10), // Very long + "\x00\x01\x02", // Binary data + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", // Max uint160 + "-1", + "0x-1", + NaN, + Infinity, + -Infinity, ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidEthereumAddress(input as string); // Just ensure no exception is thrown - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; - console.error('Ethereum address fuzz error:', e); + console.error("Ethereum address fuzz error:", e); } }); }); - it('should handle buffer-like inputs', () => { + it("should handle buffer-like inputs", () => { const buffers = [ - Buffer.from('0x742d35Cc6634C0532925a3b844Bc454e4438f44e', 'utf8'), + Buffer.from("0x742d35Cc6634C0532925a3b844Bc454e4438f44e", "utf8"), Buffer.alloc(100), Buffer.alloc(0), ]; - buffers.forEach(buf => { + buffers.forEach((buf) => { try { const result = BaseValidator.isValidEthereumAddress(buf.toString()); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { errorTests++; - console.error('Buffer test error:', e); + console.error("Buffer test error:", e); } }); }); }); - describe('isValidStellarAddress fuzz testing', () => { - it('should handle various input types without crashing', () => { + describe("isValidStellarAddress fuzz testing", () => { + it("should handle various input types without crashing", () => { const inputs: any[] = [ - null, undefined, '', randomString(10), randomNumber(1, 100), - generateStellarAddress(true), generateStellarAddress(false), - 123, {}, [], 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'.repeat(5), - '\u0000\u0001', NaN, Infinity, -Infinity + null, + undefined, + "", + randomString(10), + randomNumber(1, 100), + generateStellarAddress(true), + generateStellarAddress(false), + 123, + {}, + [], + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ".repeat(5), + "\u0000\u0001", + NaN, + Infinity, + -Infinity, ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidStellarAddress(input as string); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -164,20 +202,39 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidAddress fuzz testing', () => { - it('should handle various chain IDs with various addresses', () => { - const chainIds = [0, 1, 137, 56, 999, -1, NaN, null, undefined, ...generateRandomArray(() => randomNumber(0, 1000), 1, 20)]; + describe("isValidAddress fuzz testing", () => { + it("should handle various chain IDs with various addresses", () => { + const chainIds = [ + 0, + 1, + 137, + 56, + 999, + -1, + NaN, + null, + undefined, + ...generateRandomArray(() => randomNumber(0, 1000), 1, 20), + ]; const addresses = [ - '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', - 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', - randomString(20), randomHexString(30), '', null, undefined, 123 + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + randomString(20), + randomHexString(30), + "", + null, + undefined, + 123, ]; - chainIds.forEach(chainId => { - addresses.forEach(address => { + chainIds.forEach((chainId) => { + addresses.forEach((address) => { try { - const result = BaseValidator.isValidAddress(address as string, chainId as number); - expect(typeof result).toBe('boolean'); + const result = BaseValidator.isValidAddress( + address as string, + chainId as number, + ); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -187,22 +244,44 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidGasLimit fuzz testing', () => { - it('should handle various gas limit inputs', () => { + describe("isValidGasLimit fuzz testing", () => { + it("should handle various gas limit inputs", () => { const inputs: any[] = [ - 0, 1, 21000, 30000000, -1, -21000, - '21000', '30000000', '-1', 'invalid', - '', randomString(10), randomHexString(10), - null, undefined, {}, [], NaN, Infinity, -Infinity, - 0.1, 1.5, -0.5, 21000.5, 29999999.9, - Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, - 2**53, 2**53 - 1, + 0, + 1, + 21000, + 30000000, + -1, + -21000, + "21000", + "30000000", + "-1", + "invalid", + "", + randomString(10), + randomHexString(10), + null, + undefined, + {}, + [], + NaN, + Infinity, + -Infinity, + 0.1, + 1.5, + -0.5, + 21000.5, + 29999999.9, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + 2 ** 53, + 2 ** 53 - 1, ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidGasLimit(input); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -211,21 +290,41 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidGasPrice fuzz testing', () => { - it('should handle various gas price inputs', () => { + describe("isValidGasPrice fuzz testing", () => { + it("should handle various gas price inputs", () => { const inputs: any[] = [ - 0, 1, 1000000000, 1000000000000, -1, - '1000000000', '1000000000000', '-1', 'invalid', - '', randomString(10), randomHexString(10), - null, undefined, {}, [], NaN, Infinity, -Infinity, - 0.1, 1.5, -0.5, 999999999.9, 1000000000000.1, - Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, + 0, + 1, + 1000000000, + 1000000000000, + -1, + "1000000000", + "1000000000000", + "-1", + "invalid", + "", + randomString(10), + randomHexString(10), + null, + undefined, + {}, + [], + NaN, + Infinity, + -Infinity, + 0.1, + 1.5, + -0.5, + 999999999.9, + 1000000000000.1, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidGasPrice(input); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -234,21 +333,43 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidChainId fuzz testing', () => { - it('should handle various chain ID inputs', () => { + describe("isValidChainId fuzz testing", () => { + it("should handle various chain ID inputs", () => { const inputs: any[] = [ - 0, 1, 56, 137, 42161, 10, 43114, 250, 999, -1, - '1', '56', 'invalid', '', - null, undefined, {}, [], NaN, Infinity, -Infinity, - 0.5, 1.5, -0.5, - Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, - '0x1', '0x89', + 0, + 1, + 56, + 137, + 42161, + 10, + 43114, + 250, + 999, + -1, + "1", + "56", + "invalid", + "", + null, + undefined, + {}, + [], + NaN, + Infinity, + -Infinity, + 0.5, + 1.5, + -0.5, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + "0x1", + "0x89", ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidChainId(input as number); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -257,22 +378,38 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidTransactionType fuzz testing', () => { - it('should handle various transaction type inputs', () => { + describe("isValidTransactionType fuzz testing", () => { + it("should handle various transaction type inputs", () => { const inputs: any[] = [ - 'transfer', 'contract-call', 'swap', - randomString(10), randomString(20), '', - null, undefined, 123, {}, [], - 'Transfer', 'TRANSFER', 'Contract-Call', 'SWAP', - 'transfer ', ' transfer', 'transfer\n', - 'swap '.repeat(10), ' '.repeat(50), - '\x00\x01\x02', 'null', 'undefined', + "transfer", + "contract-call", + "swap", + randomString(10), + randomString(20), + "", + null, + undefined, + 123, + {}, + [], + "Transfer", + "TRANSFER", + "Contract-Call", + "SWAP", + "transfer ", + " transfer", + "transfer\n", + "swap ".repeat(10), + " ".repeat(50), + "\x00\x01\x02", + "null", + "undefined", ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidTransactionType(input as string); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -281,26 +418,40 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidUrl fuzz testing', () => { - it('should handle various URL inputs', () => { + describe("isValidUrl fuzz testing", () => { + it("should handle various URL inputs", () => { const inputs: any[] = [ - 'https://example.com', 'http://localhost:3000', 'git://github.com', - randomString(20), '', 'not-a-url', - 'https://', 'http://', 'git://', - 'https://example.com:65535', 'https://example.com:-1', - 'ftp://example.com', 'ws://example.com', 'wss://example.com', - null, undefined, 123, {}, [], - 'https://' + randomString(500), // Very long URL - 'javascript:alert(1)', 'data:text/html,', - '\x00\x01\x02', // Binary - 'http://example.com/path?param=' + randomString(100), // Long query - 'http://😀.com', // Emoji domain + "https://example.com", + "http://localhost:3000", + "git://github.com", + randomString(20), + "", + "not-a-url", + "https://", + "http://", + "git://", + "https://example.com:65535", + "https://example.com:-1", + "ftp://example.com", + "ws://example.com", + "wss://example.com", + null, + undefined, + 123, + {}, + [], + "https://" + randomString(500), // Very long URL + "javascript:alert(1)", + "data:text/html,", + "\x00\x01\x02", // Binary + "http://example.com/path?param=" + randomString(100), // Long query + "http://😀.com", // Emoji domain ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidUrl(input as string); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -309,21 +460,41 @@ describe('BaseValidator Fuzz Tests', () => { }); }); - describe('isValidPositiveNumber fuzz testing', () => { - it('should handle various number inputs', () => { + describe("isValidPositiveNumber fuzz testing", () => { + it("should handle various number inputs", () => { const inputs: any[] = [ - 0, 1, 100, 0.1, 0.001, -1, -0.1, - '100', '0', '-1', 'invalid', - '', randomString(10), - null, undefined, {}, [], NaN, Infinity, -Infinity, - Number.MAX_VALUE, Number.MIN_VALUE, - 1e308, 1e-308, -1e308, -1e-308, + 0, + 1, + 100, + 0.1, + 0.001, + -1, + -0.1, + "100", + "0", + "-1", + "invalid", + "", + randomString(10), + null, + undefined, + {}, + [], + NaN, + Infinity, + -Infinity, + Number.MAX_VALUE, + Number.MIN_VALUE, + 1e308, + 1e-308, + -1e308, + -1e-308, ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { const result = BaseValidator.isValidPositiveNumber(input); - expect(typeof result).toBe('boolean'); + expect(typeof result).toBe("boolean"); passedTests++; } catch (e) { failedTests++; @@ -336,7 +507,7 @@ describe('BaseValidator Fuzz Tests', () => { /** * AnalysisValidator Fuzz Tests */ -describe('AnalysisValidator Fuzz Tests', () => { +describe("AnalysisValidator Fuzz Tests", () => { let mockRequest: Partial; let mockResponse: Partial; let mockNext: jest.Mock; @@ -344,17 +515,17 @@ describe('AnalysisValidator Fuzz Tests', () => { beforeEach(() => { mockRequest = { body: {}, - headers: { 'x-request-id': 'test-fuzz-id' } + headers: { "x-request-id": "test-fuzz-id" }, }; mockResponse = { status: jest.fn().mockReturnThis(), - json: jest.fn() + json: jest.fn(), }; mockNext = jest.fn(); }); - describe('validateSubmission fuzz testing', () => { - it('should handle completely malformed input without crashing', () => { + describe("validateSubmission fuzz testing", () => { + it("should handle completely malformed input without crashing", () => { // Generate 100 random inputs for (let i = 0; i < 100; i++) { mockRequest.body = generateFuzzedObject(); @@ -364,16 +535,18 @@ describe('AnalysisValidator Fuzz Tests', () => { AnalysisValidator.validateSubmission( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); // Should either call next() or return a response, not crash - expect(mockNext.mock.calls.length >= 0 || mockResponse.json).toBeTruthy(); + expect( + mockNext.mock.calls.length >= 0 || mockResponse.json, + ).toBeTruthy(); passedTests++; } catch (e: any) { // Acceptable if it's a controlled error response, but not unhandled exceptions - if (e.message && e.message.includes('Cannot read')) { + if (e.message && e.message.includes("Cannot read")) { failedTests++; - console.error('Fuzz test crash:', e.message); + console.error("Fuzz test crash:", e.message); } else { passedTests++; // Controlled error } @@ -381,23 +554,35 @@ describe('AnalysisValidator Fuzz Tests', () => { } }); - it('should handle extreme file counts', () => { + it("should handle extreme file counts", () => { // Test with 0 files - mockRequest.body = { project: { name: 'Test' }, files: [] }; - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); - + mockRequest.body = { project: { name: "Test" }, files: [] }; + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); + // Test with 1000+ files - mockRequest.body = { - project: { name: 'Test' }, - files: generateRandomArray(() => ({ - path: randomString(20), - content: randomString(100), - language: 'rust', - size: 100 - }), 1000, 2000) + mockRequest.body = { + project: { name: "Test" }, + files: generateRandomArray( + () => ({ + path: randomString(20), + content: randomString(100), + language: "rust", + size: 100, + }), + 1000, + 2000, + ), }; try { - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { // Should handle gracefully @@ -405,49 +590,133 @@ describe('AnalysisValidator Fuzz Tests', () => { } // Test with null files - mockRequest.body = { project: { name: 'Test' }, files: null }; + mockRequest.body = { project: { name: "Test" }, files: null }; try { - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { passedTests++; } }); - it('should handle extremely large file content', () => { + it("should handle extremely large file content", () => { mockRequest.body = { - project: { name: 'Test' }, - files: [{ - path: 'test.rs', - content: randomString(10 * 1024 * 1024), // 10MB - language: 'rust', - size: 10 * 1024 * 1024 - }] + project: { name: "Test" }, + files: [ + { + path: "test.rs", + content: randomString(10 * 1024 * 1024), // 10MB + language: "rust", + size: 10 * 1024 * 1024, + }, + ], }; try { - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { passedTests++; } }); - it('should handle malicious-looking inputs', () => { + it("should handle malicious-looking inputs", () => { const maliciousInputs = [ - { project: { name: '' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: '\u0000\u0001\u0002' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: '\uD800\uDFFF' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, // Surrogates - { project: { name: ' '.repeat(1000) }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: '\t\n\r' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: null }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: undefined, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, + { + project: { name: "" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: "\u0000\u0001\u0002" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: "\uD800\uDFFF" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, // Surrogates + { + project: { name: " ".repeat(1000) }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: "\t\n\r" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: null }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: undefined, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, ]; - maliciousInputs.forEach(input => { + maliciousInputs.forEach((input) => { try { mockRequest.body = input; - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { passedTests++; @@ -455,17 +724,51 @@ describe('AnalysisValidator Fuzz Tests', () => { }); }); - it('should handle SQL injection-like patterns', () => { + it("should handle SQL injection-like patterns", () => { const injectionInputs = [ - { project: { name: "'; DROP TABLE users; --" }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: '' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, - { project: { name: '{{constructor.constructor("alert(1)")()}}' }, files: [{ path: 'test.rs', content: 'fn main() {}', language: 'rust', size: 10 }] }, + { + project: { name: "'; DROP TABLE users; --" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: "" }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, + { + project: { name: '{{constructor.constructor("alert(1)")()}}' }, + files: [ + { + path: "test.rs", + content: "fn main() {}", + language: "rust", + size: 10, + }, + ], + }, ]; - injectionInputs.forEach(input => { + injectionInputs.forEach((input) => { try { mockRequest.body = input; - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { passedTests++; @@ -473,18 +776,39 @@ describe('AnalysisValidator Fuzz Tests', () => { }); }); - it('should handle array-based attacks', () => { + it("should handle array-based attacks", () => { const attackInputs = [ - { files: [{ path: 'a'.repeat(10000), content: 'x', language: 'rust', size: 1 }] }, - { project: { name: 'A' }, files: { '0': { path: 'a', content: 'b', language: 'rust', size: 1 } } }, - { project: { name: 'A' }, files: 'invalid' as any }, - { project: { name: 'A' }, files: [undefined, null, {}, [], 1, 'string'] }, + { + files: [ + { + path: "a".repeat(10000), + content: "x", + language: "rust", + size: 1, + }, + ], + }, + { + project: { name: "A" }, + files: { + "0": { path: "a", content: "b", language: "rust", size: 1 }, + }, + }, + { project: { name: "A" }, files: "invalid" as any }, + { + project: { name: "A" }, + files: [undefined, null, {}, [], 1, "string"], + }, ]; - attackInputs.forEach(input => { + attackInputs.forEach((input) => { try { mockRequest.body = input; - AnalysisValidator.validateSubmission(mockRequest as Request, mockResponse as Response, mockNext); + AnalysisValidator.validateSubmission( + mockRequest as Request, + mockResponse as Response, + mockNext, + ); passedTests++; } catch (e) { passedTests++; @@ -497,7 +821,7 @@ describe('AnalysisValidator Fuzz Tests', () => { /** * CrossChainGasValidator Fuzz Tests */ -describe('CrossChainGasValidator Fuzz Tests', () => { +describe("CrossChainGasValidator Fuzz Tests", () => { let mockRequest: Partial; let mockResponse: Partial; let mockNext: jest.Mock; @@ -506,32 +830,44 @@ describe('CrossChainGasValidator Fuzz Tests', () => { mockRequest = { query: {}, params: {}, - headers: { 'x-request-id': 'test-fuzz-id' } + headers: { "x-request-id": "test-fuzz-id" }, }; mockResponse = { status: jest.fn().mockReturnThis(), - json: jest.fn() + json: jest.fn(), }; mockNext = jest.fn(); }); - describe('validateTransactionType fuzz testing', () => { - it('should handle various transaction type inputs', () => { + describe("validateTransactionType fuzz testing", () => { + it("should handle various transaction type inputs", () => { const inputs: any[] = [ - 'transfer', 'contract-call', 'swap', - randomString(20), '', null, undefined, - 123, {}, [], true, false, - 'Transfer', 'CONTRACT-CALL', 'Swap', - ' '.repeat(10), '\t\n\r', + "transfer", + "contract-call", + "swap", + randomString(20), + "", + null, + undefined, + 123, + {}, + [], + true, + false, + "Transfer", + "CONTRACT-CALL", + "Swap", + " ".repeat(10), + "\t\n\r", ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { mockRequest.query = { txType: input }; CrossChainGasValidator.validateTransactionType( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -541,23 +877,35 @@ describe('CrossChainGasValidator Fuzz Tests', () => { }); }); - describe('validateChainIdParam fuzz testing', () => { - it('should handle various chain ID inputs', () => { + describe("validateChainIdParam fuzz testing", () => { + it("should handle various chain ID inputs", () => { const inputs: any[] = [ - '1', '56', '137', '42161', - randomString(10), '', null, undefined, - 'abc', '-1', '65535', '999999', - '0x1', '0x89', - '1.5', '1a', '@1', + "1", + "56", + "137", + "42161", + randomString(10), + "", + null, + undefined, + "abc", + "-1", + "65535", + "999999", + "0x1", + "0x89", + "1.5", + "1a", + "@1", ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { mockRequest.params = { chainId: input }; CrossChainGasValidator.validateChainIdParam( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -567,30 +915,36 @@ describe('CrossChainGasValidator Fuzz Tests', () => { }); }); - describe('validateDateRange fuzz testing', () => { - it('should handle various date inputs', () => { + describe("validateDateRange fuzz testing", () => { + it("should handle various date inputs", () => { const dateInputs = [ - { startDate: '2024-01-01T00:00:00.000Z', endDate: '2024-12-31T23:59:59.999Z' }, + { + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-12-31T23:59:59.999Z", + }, { startDate: randomString(20), endDate: randomString(20) }, - { startDate: '', endDate: '' }, + { startDate: "", endDate: "" }, { startDate: null, endDate: null }, - { startDate: 'invalid', endDate: '2024-01-01' }, - { startDate: '2024-13-01', endDate: '2024-01-01' }, - { startDate: '2024-01-32', endDate: '2024-01-01' }, - { startDate: '2024-01-01', endDate: '2023-12-31' }, - { startDate: '1900-01-01T00:00:00Z', endDate: '2100-12-31T23:59:59Z' }, - { startDate: '2024-01-01T00:00:00+00:00', endDate: '2024-01-01T00:00:00-00:00' }, + { startDate: "invalid", endDate: "2024-01-01" }, + { startDate: "2024-13-01", endDate: "2024-01-01" }, + { startDate: "2024-01-32", endDate: "2024-01-01" }, + { startDate: "2024-01-01", endDate: "2023-12-31" }, + { startDate: "1900-01-01T00:00:00Z", endDate: "2100-12-31T23:59:59Z" }, + { + startDate: "2024-01-01T00:00:00+00:00", + endDate: "2024-01-01T00:00:00-00:00", + }, { startDate: {}, endDate: [] }, { startDate: 123, endDate: 456 }, ]; - dateInputs.forEach(input => { + dateInputs.forEach((input) => { try { mockRequest.query = input as any; CrossChainGasValidator.validateDateRange( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -604,7 +958,7 @@ describe('CrossChainGasValidator Fuzz Tests', () => { /** * FailedTransactionValidator Fuzz Tests */ -describe('FailedTransactionValidator Fuzz Tests', () => { +describe("FailedTransactionValidator Fuzz Tests", () => { let mockRequest: Partial; let mockResponse: Partial; let mockNext: jest.Mock; @@ -614,17 +968,17 @@ describe('FailedTransactionValidator Fuzz Tests', () => { body: {}, params: {}, query: {}, - headers: { 'x-request-id': 'test-fuzz-id' } + headers: { "x-request-id": "test-fuzz-id" }, }; mockResponse = { status: jest.fn().mockReturnThis(), - json: jest.fn() + json: jest.fn(), }; mockNext = jest.fn(); }); - describe('validateTransactionAnalysis fuzz testing', () => { - it('should handle various transaction analysis inputs', () => { + describe("validateTransactionAnalysis fuzz testing", () => { + it("should handle various transaction analysis inputs", () => { for (let i = 0; i < 50; i++) { mockRequest.body = generateFuzzedObject(); @@ -632,7 +986,7 @@ describe('FailedTransactionValidator Fuzz Tests', () => { FailedTransactionValidator.validateTransactionAnalysis( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -642,24 +996,33 @@ describe('FailedTransactionValidator Fuzz Tests', () => { } }); - it('should handle extreme chain ID arrays', () => { + it("should handle extreme chain ID arrays", () => { const extremeArrays = [ { chainIds: [] }, - { chainIds: generateRandomArray(() => randomNumber(-1000, 1000), 1000, 2000) }, + { + chainIds: generateRandomArray( + () => randomNumber(-1000, 1000), + 1000, + 2000, + ), + }, { chainIds: null }, { chainIds: undefined }, - { chainIds: '1,2,3' }, + { chainIds: "1,2,3" }, { chainIds: [{ a: 1 }, { b: 2 }] }, - { chainIds: [1, '2', 3, null, undefined] }, + { chainIds: [1, "2", 3, null, undefined] }, ]; - extremeArrays.forEach(input => { + extremeArrays.forEach((input) => { try { - mockRequest.body = { wallet: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', ...input }; + mockRequest.body = { + wallet: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + ...input, + }; FailedTransactionValidator.validateTransactionAnalysis( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -668,27 +1031,31 @@ describe('FailedTransactionValidator Fuzz Tests', () => { }); }); - it('should handle extreme wallet addresses', () => { + it("should handle extreme wallet addresses", () => { const wallets = [ - '0x' + 'a'.repeat(100), // Very long - '', // Empty - '0x', // Just prefix - '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', // Max + "0x" + "a".repeat(100), // Very long + "", // Empty + "0x", // Just prefix + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", // Max generateEthereumAddress(true), generateStellarAddress(true), randomString(40), - '0x742d35Cc6634C0532925a3b844Bc454e4438f44e '.repeat(10), - '\x00\x01\x02', - 123, null, undefined, {}, [], + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e ".repeat(10), + "\x00\x01\x02", + 123, + null, + undefined, + {}, + [], ]; - wallets.forEach(wallet => { + wallets.forEach((wallet) => { try { mockRequest.body = { wallet }; FailedTransactionValidator.validateTransactionAnalysis( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -698,22 +1065,29 @@ describe('FailedTransactionValidator Fuzz Tests', () => { }); }); - describe('validateWalletParam fuzz testing', () => { - it('should handle various wallet parameter inputs', () => { + describe("validateWalletParam fuzz testing", () => { + it("should handle various wallet parameter inputs", () => { const inputs: any[] = [ - '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', - 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', - randomString(40), randomHexString(30), '', null, undefined, - 123, {}, [], '0x'.repeat(20), + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + randomString(40), + randomHexString(30), + "", + null, + undefined, + 123, + {}, + [], + "0x".repeat(20), ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { mockRequest.params = { wallet: input }; FailedTransactionValidator.validateWalletParam( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -723,25 +1097,31 @@ describe('FailedTransactionValidator Fuzz Tests', () => { }); }); - describe('validateChainIdsQuery fuzz testing', () => { - it('should handle various chain ID query inputs', () => { + describe("validateChainIdsQuery fuzz testing", () => { + it("should handle various chain ID query inputs", () => { const inputs: any[] = [ - '1,56,137', // Valid comma-separated - '1,2,3,4,5,6,7,8,9,10', - randomString(20), '', null, undefined, - 'a,b,c', '1,2,three', - ',,,', '1,,2,3', - ' '.repeat(10), - '1-2-3', '1.2.3', + "1,56,137", // Valid comma-separated + "1,2,3,4,5,6,7,8,9,10", + randomString(20), + "", + null, + undefined, + "a,b,c", + "1,2,three", + ",,,", + "1,,2,3", + " ".repeat(10), + "1-2-3", + "1.2.3", ]; - inputs.forEach(input => { + inputs.forEach((input) => { try { mockRequest.query = { chainIds: input }; FailedTransactionValidator.validateChainIdsQuery( mockRequest as Request, mockResponse as Response, - mockNext + mockNext, ); passedTests++; } catch (e) { @@ -755,13 +1135,13 @@ describe('FailedTransactionValidator Fuzz Tests', () => { /** * Summary of fuzz tests */ -describe('Fuzz Test Summary', () => { - it('should complete all fuzz tests without critical failures', () => { +describe("Fuzz Test Summary", () => { + it("should complete all fuzz tests without critical failures", () => { console.log(`\n=== Fuzz Test Results ===`); console.log(`Passed: ${passedTests}`); console.log(`Failed: ${failedTests}`); console.log(`Errors: ${errorTests}`); - + // We expect most tests to pass - some failures are acceptable for fuzz tests // as long as no unhandled exceptions crash the test suite expect(passedTests).toBeGreaterThan(0); diff --git a/apps/api/src/analytics/analytics.controller.ts b/apps/api/src/analytics/analytics.controller.ts index 900131f..f5892cc 100644 --- a/apps/api/src/analytics/analytics.controller.ts +++ b/apps/api/src/analytics/analytics.controller.ts @@ -1,92 +1,163 @@ -import { Controller, Get, Post, Query, Body, Param } from '@nestjs/common'; -import { AnalyticsService } from './analytics.service'; -import { GasSavingsDto, DashboardQueryDto } from './dto/gas-savings.dto'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { Controller, Get, Post, Query, Body, Param } from "@nestjs/common"; +import { AnalyticsService } from "./analytics.service"; +import { GasSavingsDto, DashboardQueryDto } from "./dto/gas-savings.dto"; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from "@nestjs/swagger"; -@ApiTags('analytics') -@Controller('analytics') +@ApiTags("analytics") +@Controller("analytics") export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService) {} - @Get('dashboard') - @ApiOperation({ summary: 'Get comprehensive dashboard data' }) - @ApiQuery({ name: 'projectId', required: false, description: 'Filter by project ID' }) - @ApiResponse({ status: 200, description: 'Dashboard data retrieved successfully' }) + @Get("dashboard") + @ApiOperation({ summary: "Get comprehensive dashboard data" }) + @ApiQuery({ + name: "projectId", + required: false, + description: "Filter by project ID", + }) + @ApiResponse({ + status: 200, + description: "Dashboard data retrieved successfully", + }) async getDashboard(@Query() query: DashboardQueryDto) { return this.analyticsService.getDashboardData(query.projectId); } - @Get('savings/total') - @ApiOperation({ summary: 'Get total gas savings across all scans' }) - @ApiResponse({ status: 200, description: 'Total savings retrieved successfully' }) + @Get("savings/total") + @ApiOperation({ summary: "Get total gas savings across all scans" }) + @ApiResponse({ + status: 200, + description: "Total savings retrieved successfully", + }) async getTotalSavings() { const total = await this.analyticsService.getTotalSavings(); return { totalSavings: total }; } - @Get('savings/projects') - @ApiOperation({ summary: 'Get gas savings aggregated by project' }) - @ApiResponse({ status: 200, description: 'Project savings retrieved successfully' }) + @Get("savings/projects") + @ApiOperation({ summary: "Get gas savings aggregated by project" }) + @ApiResponse({ + status: 200, + description: "Project savings retrieved successfully", + }) async getSavingsByProject() { return this.analyticsService.getSavingsByProject(); } - @Get('savings/projects/:projectId/files') - @ApiOperation({ summary: 'Get gas savings by file for a specific project' }) - @ApiResponse({ status: 200, description: 'File savings retrieved successfully' }) - async getSavingsByFile(@Param('projectId') projectId: string) { + @Get("savings/projects/:projectId/files") + @ApiOperation({ summary: "Get gas savings by file for a specific project" }) + @ApiResponse({ + status: 200, + description: "File savings retrieved successfully", + }) + async getSavingsByFile(@Param("projectId") projectId: string) { return this.analyticsService.getSavingsByFile(projectId); } - @Get('savings/rules') - @ApiOperation({ summary: 'Get gas savings aggregated by rule' }) - @ApiQuery({ name: 'projectId', required: false, description: 'Filter by project ID' }) - @ApiResponse({ status: 200, description: 'Rule savings retrieved successfully' }) - async getSavingsByRule(@Query('projectId') projectId?: string) { + @Get("savings/rules") + @ApiOperation({ summary: "Get gas savings aggregated by rule" }) + @ApiQuery({ + name: "projectId", + required: false, + description: "Filter by project ID", + }) + @ApiResponse({ + status: 200, + description: "Rule savings retrieved successfully", + }) + async getSavingsByRule(@Query("projectId") projectId?: string) { return this.analyticsService.getSavingsByRule(projectId); } - @Get('savings/timeseries') - @ApiOperation({ summary: 'Get gas savings time series data' }) - @ApiQuery({ name: 'projectId', required: false, description: 'Filter by project ID' }) - @ApiQuery({ name: 'startDate', required: false, description: 'Start date (ISO string)' }) - @ApiQuery({ name: 'endDate', required: false, description: 'End date (ISO string)' }) - @ApiQuery({ name: 'granularity', required: false, enum: ['hour', 'day', 'week', 'month'], description: 'Time granularity' }) - @ApiResponse({ status: 200, description: 'Time series data retrieved successfully' }) + @Get("savings/timeseries") + @ApiOperation({ summary: "Get gas savings time series data" }) + @ApiQuery({ + name: "projectId", + required: false, + description: "Filter by project ID", + }) + @ApiQuery({ + name: "startDate", + required: false, + description: "Start date (ISO string)", + }) + @ApiQuery({ + name: "endDate", + required: false, + description: "End date (ISO string)", + }) + @ApiQuery({ + name: "granularity", + required: false, + enum: ["hour", "day", "week", "month"], + description: "Time granularity", + }) + @ApiResponse({ + status: 200, + description: "Time series data retrieved successfully", + }) async getSavingsTimeSeries( - @Query('projectId') projectId?: string, - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, - @Query('granularity') granularity: 'hour' | 'day' | 'week' | 'month' = 'day' + @Query("projectId") projectId?: string, + @Query("startDate") startDate?: string, + @Query("endDate") endDate?: string, + @Query("granularity") + granularity: "hour" | "day" | "week" | "month" = "day", ) { const start = startDate ? new Date(startDate) : undefined; const end = endDate ? new Date(endDate) : undefined; - return this.analyticsService.getSavingsTimeSeries(projectId, start, end, granularity); + return this.analyticsService.getSavingsTimeSeries( + projectId, + start, + end, + granularity, + ); } - @Get('savings/severity') - @ApiOperation({ summary: 'Get gas savings by severity level' }) - @ApiQuery({ name: 'projectId', required: false, description: 'Filter by project ID' }) - @ApiResponse({ status: 200, description: 'Severity savings retrieved successfully' }) - async getSavingsBySeverity(@Query('projectId') projectId?: string) { + @Get("savings/severity") + @ApiOperation({ summary: "Get gas savings by severity level" }) + @ApiQuery({ + name: "projectId", + required: false, + description: "Filter by project ID", + }) + @ApiResponse({ + status: 200, + description: "Severity savings retrieved successfully", + }) + async getSavingsBySeverity(@Query("projectId") projectId?: string) { return this.analyticsService.getSavingsBySeverity(projectId); } - @Get('optimizations/top') - @ApiOperation({ summary: 'Get top optimization opportunities' }) - @ApiQuery({ name: 'projectId', required: false, description: 'Filter by project ID' }) - @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of results to return' }) - @ApiResponse({ status: 200, description: 'Top optimizations retrieved successfully' }) + @Get("optimizations/top") + @ApiOperation({ summary: "Get top optimization opportunities" }) + @ApiQuery({ + name: "projectId", + required: false, + description: "Filter by project ID", + }) + @ApiQuery({ + name: "limit", + required: false, + type: Number, + description: "Number of results to return", + }) + @ApiResponse({ + status: 200, + description: "Top optimizations retrieved successfully", + }) async getTopOptimizations( - @Query('projectId') projectId?: string, - @Query('limit') limit: number = 10 + @Query("projectId") projectId?: string, + @Query("limit") limit: number = 10, ) { return this.analyticsService.getTopOptimizations(projectId, limit); } - @Post('savings') - @ApiOperation({ summary: 'Record gas savings from a scan' }) - @ApiResponse({ status: 201, description: 'Gas savings recorded successfully' }) + @Post("savings") + @ApiOperation({ summary: "Record gas savings from a scan" }) + @ApiResponse({ + status: 201, + description: "Gas savings recorded successfully", + }) async recordGasSavings(@Body() savings: GasSavingsDto[]) { return this.analyticsService.recordGasSavings(savings); } diff --git a/apps/api/src/analytics/analytics.module.ts b/apps/api/src/analytics/analytics.module.ts index 3b3d408..4a3211e 100644 --- a/apps/api/src/analytics/analytics.module.ts +++ b/apps/api/src/analytics/analytics.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { AnalyticsController } from './analytics.controller'; -import { AnalyticsService } from './analytics.service'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { GasSavings } from './entities/gas-savings.entity'; +import { Module } from "@nestjs/common"; +import { AnalyticsController } from "./analytics.controller"; +import { AnalyticsService } from "./analytics.service"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { GasSavings } from "./entities/gas-savings.entity"; @Module({ imports: [TypeOrmModule.forFeature([GasSavings])], diff --git a/apps/api/src/analytics/analytics.service.ts b/apps/api/src/analytics/analytics.service.ts index 68404f1..4bf5aff 100644 --- a/apps/api/src/analytics/analytics.service.ts +++ b/apps/api/src/analytics/analytics.service.ts @@ -1,8 +1,13 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, SelectQueryBuilder } from 'typeorm'; -import { GasSavings } from './entities/gas-savings.entity'; -import { GasSavingsDto, ProjectSavingsDto, RuleSavingsDto, TimeSeriesSavingsDto } from './dto/gas-savings.dto'; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository, Between, SelectQueryBuilder } from "typeorm"; +import { GasSavings } from "./entities/gas-savings.entity"; +import { + GasSavingsDto, + ProjectSavingsDto, + RuleSavingsDto, + TimeSeriesSavingsDto, +} from "./dto/gas-savings.dto"; @Injectable() export class AnalyticsService { @@ -16,10 +21,10 @@ export class AnalyticsService { */ async getTotalSavings(): Promise { const result = await this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('SUM(gas_savings.gasSaved)', 'total') + .createQueryBuilder("gas_savings") + .select("SUM(gas_savings.gasSaved)", "total") .getRawOne(); - + return result?.total ? parseInt(result.total) : 0; } @@ -28,13 +33,13 @@ export class AnalyticsService { */ async getSavingsByProject(): Promise { return this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('gas_savings.projectId', 'projectId') - .addSelect('COUNT(DISTINCT gas_savings.scanId)', 'scanCount') - .addSelect('COUNT(*)', 'issueCount') - .addSelect('SUM(gas_savings.gasSaved)', 'totalGasSaved') - .groupBy('gas_savings.projectId') - .orderBy('totalGasSaved', 'DESC') + .createQueryBuilder("gas_savings") + .select("gas_savings.projectId", "projectId") + .addSelect("COUNT(DISTINCT gas_savings.scanId)", "scanCount") + .addSelect("COUNT(*)", "issueCount") + .addSelect("SUM(gas_savings.gasSaved)", "totalGasSaved") + .groupBy("gas_savings.projectId") + .orderBy("totalGasSaved", "DESC") .getRawMany(); } @@ -43,14 +48,14 @@ export class AnalyticsService { */ async getSavingsByFile(projectId: string): Promise { return this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('gas_savings.fileName', 'fileName') - .addSelect('COUNT(*)', 'issueCount') - .addSelect('SUM(gas_savings.gasSaved)', 'totalGasSaved') - .addSelect('AVG(gas_savings.severity)', 'averageSeverity') - .where('gas_savings.projectId = :projectId', { projectId }) - .groupBy('gas_savings.fileName') - .orderBy('totalGasSaved', 'DESC') + .createQueryBuilder("gas_savings") + .select("gas_savings.fileName", "fileName") + .addSelect("COUNT(*)", "issueCount") + .addSelect("SUM(gas_savings.gasSaved)", "totalGasSaved") + .addSelect("AVG(gas_savings.severity)", "averageSeverity") + .where("gas_savings.projectId = :projectId", { projectId }) + .groupBy("gas_savings.fileName") + .orderBy("totalGasSaved", "DESC") .getRawMany(); } @@ -59,17 +64,17 @@ export class AnalyticsService { */ async getSavingsByRule(projectId?: string): Promise { const query = this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('gas_savings.ruleId', 'ruleId') - .addSelect('gas_savings.ruleName', 'ruleName') - .addSelect('COUNT(*)', 'applicationCount') - .addSelect('SUM(gas_savings.gasSaved)', 'totalGasSaved') - .addSelect('AVG(gas_savings.gasSaved)', 'averageGasSaved') - .groupBy('gas_savings.ruleId, gas_savings.ruleName') - .orderBy('totalGasSaved', 'DESC'); + .createQueryBuilder("gas_savings") + .select("gas_savings.ruleId", "ruleId") + .addSelect("gas_savings.ruleName", "ruleName") + .addSelect("COUNT(*)", "applicationCount") + .addSelect("SUM(gas_savings.gasSaved)", "totalGasSaved") + .addSelect("AVG(gas_savings.gasSaved)", "averageGasSaved") + .groupBy("gas_savings.ruleId, gas_savings.ruleName") + .orderBy("totalGasSaved", "DESC"); if (projectId) { - query.where('gas_savings.projectId = :projectId', { projectId }); + query.where("gas_savings.projectId = :projectId", { projectId }); } return query.getRawMany(); @@ -82,31 +87,36 @@ export class AnalyticsService { projectId?: string, startDate?: Date, endDate?: Date, - granularity: 'hour' | 'day' | 'week' | 'month' = 'day' + granularity: "hour" | "day" | "week" | "month" = "day", ): Promise { - let query = this.gasSavingsRepository - .createQueryBuilder('gas_savings'); + let query = this.gasSavingsRepository.createQueryBuilder("gas_savings"); if (projectId) { - query = query.where('gas_savings.projectId = :projectId', { projectId }); + query = query.where("gas_savings.projectId = :projectId", { projectId }); } if (startDate && endDate) { - query = query.andWhere('gas_savings.createdAt BETWEEN :startDate AND :endDate', { - startDate, - endDate, - }); + query = query.andWhere( + "gas_savings.createdAt BETWEEN :startDate AND :endDate", + { + startDate, + endDate, + }, + ); } const dateFormat = this.getDateFormat(granularity); return query - .select(`DATE_FORMAT(gas_savings.createdAt, '${dateFormat}')`, 'timeBucket') - .addSelect('COUNT(*)', 'issueCount') - .addSelect('SUM(gas_savings.gasSaved)', 'totalGasSaved') - .addSelect('COUNT(DISTINCT gas_savings.scanId)', 'scanCount') + .select( + `DATE_FORMAT(gas_savings.createdAt, '${dateFormat}')`, + "timeBucket", + ) + .addSelect("COUNT(*)", "issueCount") + .addSelect("SUM(gas_savings.gasSaved)", "totalGasSaved") + .addSelect("COUNT(DISTINCT gas_savings.scanId)", "scanCount") .groupBy(`DATE_FORMAT(gas_savings.createdAt, '${dateFormat}')`) - .orderBy('timeBucket', 'ASC') + .orderBy("timeBucket", "ASC") .getRawMany(); } @@ -115,15 +125,15 @@ export class AnalyticsService { */ async getSavingsBySeverity(projectId?: string): Promise { const query = this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('gas_savings.severity', 'severity') - .addSelect('COUNT(*)', 'issueCount') - .addSelect('SUM(gas_savings.gasSaved)', 'totalGasSaved') - .groupBy('gas_savings.severity') - .orderBy('severity', 'DESC'); + .createQueryBuilder("gas_savings") + .select("gas_savings.severity", "severity") + .addSelect("COUNT(*)", "issueCount") + .addSelect("SUM(gas_savings.gasSaved)", "totalGasSaved") + .groupBy("gas_savings.severity") + .orderBy("severity", "DESC"); if (projectId) { - query.where('gas_savings.projectId = :projectId', { projectId }); + query.where("gas_savings.projectId = :projectId", { projectId }); } return query.getRawMany(); @@ -132,19 +142,22 @@ export class AnalyticsService { /** * Get top optimization opportunities */ - async getTopOptimizations(projectId?: string, limit: number = 10): Promise { + async getTopOptimizations( + projectId?: string, + limit: number = 10, + ): Promise { const query = this.gasSavingsRepository - .createQueryBuilder('gas_savings') - .select('gas_savings.fileName', 'fileName') - .addSelect('gas_savings.ruleName', 'ruleName') - .addSelect('gas_savings.description', 'description') - .addSelect('gas_savings.gasSaved', 'gasSaved') - .addSelect('gas_savings.lineNumber', 'lineNumber') - .addSelect('gas_savings.severity', 'severity') - .orderBy('gas_savings.gasSaved', 'DESC'); + .createQueryBuilder("gas_savings") + .select("gas_savings.fileName", "fileName") + .addSelect("gas_savings.ruleName", "ruleName") + .addSelect("gas_savings.description", "description") + .addSelect("gas_savings.gasSaved", "gasSaved") + .addSelect("gas_savings.lineNumber", "lineNumber") + .addSelect("gas_savings.severity", "severity") + .orderBy("gas_savings.gasSaved", "DESC"); if (projectId) { - query.where('gas_savings.projectId = :projectId', { projectId }); + query.where("gas_savings.projectId = :projectId", { projectId }); } return query.limit(limit).getRawMany(); @@ -160,14 +173,19 @@ export class AnalyticsService { ruleSavings, severityBreakdown, topOptimizations, - recentActivity + recentActivity, ] = await Promise.all([ this.getTotalSavings(), this.getSavingsByProject(), this.getSavingsByRule(projectId), this.getSavingsBySeverity(projectId), this.getTopOptimizations(projectId, 5), - this.getSavingsTimeSeries(projectId, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), new Date(), 'day') + this.getSavingsTimeSeries( + projectId, + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + new Date(), + "day", + ), ]); return { @@ -176,7 +194,9 @@ export class AnalyticsService { totalProjects: projectSavings.length, totalRules: ruleSavings.length, }, - projectSavings: projectId ? projectSavings.filter(p => p.projectId === projectId) : projectSavings, + projectSavings: projectId + ? projectSavings.filter((p) => p.projectId === projectId) + : projectSavings, ruleSavings, severityBreakdown: this.formatSeverityData(severityBreakdown), topOptimizations, @@ -188,7 +208,7 @@ export class AnalyticsService { * Record gas savings from a scan */ async recordGasSavings(savings: GasSavingsDto[]): Promise { - const entities = savings.map(saving => { + const entities = savings.map((saving) => { const entity = new GasSavings(); entity.projectId = saving.projectId; entity.scanId = saving.scanId; @@ -208,24 +228,24 @@ export class AnalyticsService { private getDateFormat(granularity: string): string { switch (granularity) { - case 'hour': - return '%Y-%m-%d %H:00:00'; - case 'day': - return '%Y-%m-%d'; - case 'week': - return '%Y-%u'; - case 'month': - return '%Y-%m'; + case "hour": + return "%Y-%m-%d %H:00:00"; + case "day": + return "%Y-%m-%d"; + case "week": + return "%Y-%u"; + case "month": + return "%Y-%m"; default: - return '%Y-%m-%d'; + return "%Y-%m-%d"; } } private formatSeverityData(severityData: any[]): any[] { - const severityNames = ['Info', 'Warning', 'Error', 'Critical']; - return severityData.map(item => ({ + const severityNames = ["Info", "Warning", "Error", "Critical"]; + return severityData.map((item) => ({ ...item, - severityName: severityNames[item.severity - 1] || 'Unknown', + severityName: severityNames[item.severity - 1] || "Unknown", })); } } diff --git a/apps/api/src/analytics/dto/gas-savings.dto.ts b/apps/api/src/analytics/dto/gas-savings.dto.ts index 5983bd1..a83dacb 100644 --- a/apps/api/src/analytics/dto/gas-savings.dto.ts +++ b/apps/api/src/analytics/dto/gas-savings.dto.ts @@ -37,6 +37,6 @@ export class DashboardQueryDto { projectId?: string; startDate?: string; endDate?: string; - granularity?: 'hour' | 'day' | 'week' | 'month'; + granularity?: "hour" | "day" | "week" | "month"; limit?: number; } diff --git a/apps/api/src/analytics/entities/gas-savings.entity.ts b/apps/api/src/analytics/entities/gas-savings.entity.ts index 912ef7b..a7db44c 100644 --- a/apps/api/src/analytics/entities/gas-savings.entity.ts +++ b/apps/api/src/analytics/entities/gas-savings.entity.ts @@ -1,10 +1,16 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; - -@Entity('gas_savings') -@Index(['projectId', 'scanId']) -@Index(['createdAt']) +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +@Entity("gas_savings") +@Index(["projectId", "scanId"]) +@Index(["createdAt"]) export class GasSavings { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; @Column() @@ -22,19 +28,19 @@ export class GasSavings { @Column() ruleName: string; - @Column('int') + @Column("int") gasSaved: number; - @Column('int') + @Column("int") severity: number; // 1=Info, 2=Warning, 3=Error, 4=Critical - @Column('text', { nullable: true }) + @Column("text", { nullable: true }) description: string; - @Column('text', { nullable: true }) + @Column("text", { nullable: true }) suggestion: string; - @Column('int') + @Column("int") lineNumber: number; @CreateDateColumn() diff --git a/apps/api/src/analytics/ml/detector.ts b/apps/api/src/analytics/ml/detector.ts index 1e03a10..dc97f08 100644 --- a/apps/api/src/analytics/ml/detector.ts +++ b/apps/api/src/analytics/ml/detector.ts @@ -34,8 +34,7 @@ export class MLDetector { mean[key] = avg; const variance = - values.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / - values.length; + values.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / values.length; std[key] = Math.sqrt(variance); } @@ -45,9 +44,27 @@ export class MLDetector { public predict(userId: string, input: FeatureVector): AnomalyResult { const score = - Math.abs(zScore(input.frequency, this.stats.mean.frequency, this.stats.std.frequency)) + - Math.abs(zScore(input.timeGapAvg, this.stats.mean.timeGapAvg, this.stats.std.timeGapAvg)) + - Math.abs(zScore(input.errorRate, this.stats.mean.errorRate, this.stats.std.errorRate)); + Math.abs( + zScore( + input.frequency, + this.stats.mean.frequency, + this.stats.std.frequency, + ), + ) + + Math.abs( + zScore( + input.timeGapAvg, + this.stats.mean.timeGapAvg, + this.stats.std.timeGapAvg, + ), + ) + + Math.abs( + zScore( + input.errorRate, + this.stats.mean.errorRate, + this.stats.std.errorRate, + ), + ); const isAnomaly = score > 3; // threshold (tunable) @@ -57,4 +74,4 @@ export class MLDetector { isAnomaly, }; } -} \ No newline at end of file +} diff --git a/apps/api/src/analytics/ml/feature-extractor.ts b/apps/api/src/analytics/ml/feature-extractor.ts index 351892e..37d8362 100644 --- a/apps/api/src/analytics/ml/feature-extractor.ts +++ b/apps/api/src/analytics/ml/feature-extractor.ts @@ -7,7 +7,7 @@ export function extractFeatures(events: EventRecord[]): FeatureVector { const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); - let gaps: number[] = []; + const gaps: number[] = []; let errors = 0; for (let i = 1; i < sorted.length; i++) { @@ -23,4 +23,4 @@ export function extractFeatures(events: EventRecord[]): FeatureVector { timeGapAvg: gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0, errorRate: errors / events.length, }; -} \ No newline at end of file +} diff --git a/apps/api/src/analytics/ml/pipeline-hook.ts b/apps/api/src/analytics/ml/pipeline-hook.ts index f42843a..213ca9b 100644 --- a/apps/api/src/analytics/ml/pipeline-hook.ts +++ b/apps/api/src/analytics/ml/pipeline-hook.ts @@ -20,4 +20,4 @@ export class AnalysisMLPipeline { anomaly: result, }; } -} \ No newline at end of file +} diff --git a/apps/api/src/analytics/ml/types.ts b/apps/api/src/analytics/ml/types.ts index 0cad411..0128ddb 100644 --- a/apps/api/src/analytics/ml/types.ts +++ b/apps/api/src/analytics/ml/types.ts @@ -15,4 +15,4 @@ export type AnomalyResult = { userId: string; score: number; isAnomaly: boolean; -}; \ No newline at end of file +}; diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index ee5778c..b399b8d 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,30 +1,30 @@ -import { Controller, Get } from '@nestjs/common'; -import { Public } from './auth'; +import { Controller, Get } from "@nestjs/common"; +import { Public } from "./auth"; @Controller() export class AppController { - /** - * Root endpoint - API info - * Public endpoint for API discovery - */ - @Public() - @Get() - getRoot(): { name: string; version: string; health: string } { - return { - name: 'GasGuard API', - version: '0.1.0', - health: '/health', - }; - } + /** + * Root endpoint - API info + * Public endpoint for API discovery + */ + @Public() + @Get() + getRoot(): { name: string; version: string; health: string } { + return { + name: "GasGuard API", + version: "0.1.0", + health: "/health", + }; + } - /** - * Health check endpoint - * Returns a simple status to verify the API is running - * Public endpoint for monitoring and load balancers - */ - @Public() - @Get('health') - getHealth(): { status: string } { - return { status: 'ok' }; - } + /** + * Health check endpoint + * Returns a simple status to verify the API is running + * Public endpoint for monitoring and load balancers + */ + @Public() + @Get("health") + getHealth(): { status: string } { + return { status: "ok" }; + } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b28111d..dac66df 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,70 +1,70 @@ -import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; -import { ConfigModule } from '@nestjs/config'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { AppController } from './app.controller'; -import { ExampleController } from './example/example.controller'; -import { FailedTransactionController } from './controllers/failed-transaction.controller'; -import { CrossChainGasController } from './controllers/cross-chain-gas.controller'; -import { FailedTransactionService } from './services/failed-transaction.service'; -import { MitigationService } from './services/mitigation.service'; -import { TransactionAnalysisService } from './services/transaction-analysis.service'; -import { CrossChainGasService } from './services/cross-chain-gas.service'; -import { RateLimitingModule, RateLimitGuard } from './rate-limiting'; -import { AuthModule, JwtAuthGuard, RolesGuard } from './auth'; -import { ExportsModule } from './exports/exports.module'; -import { ScanModule } from './modules/scan/scan.module'; +import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ConfigModule } from "@nestjs/config"; +import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; +import { AppController } from "./app.controller"; +import { ExampleController } from "./example/example.controller"; +import { FailedTransactionController } from "./controllers/failed-transaction.controller"; +import { CrossChainGasController } from "./controllers/cross-chain-gas.controller"; +import { FailedTransactionService } from "./services/failed-transaction.service"; +import { MitigationService } from "./services/mitigation.service"; +import { TransactionAnalysisService } from "./services/transaction-analysis.service"; +import { CrossChainGasService } from "./services/cross-chain-gas.service"; +import { RateLimitingModule, RateLimitGuard } from "./rate-limiting"; +import { AuthModule, JwtAuthGuard, RolesGuard } from "./auth"; +import { ExportsModule } from "./exports/exports.module"; +import { ScanModule } from "./modules/scan/scan.module"; @Module({ - imports: [ - // Global configuration module for environment variables - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: ['.env', '.env.local'], - }), - ThrottlerModule.forRoot([ - { - name: 'default', - ttl: 60000, // 60 seconds in milliseconds - limit: 100, // 100 requests per TTL window (generous fallback) - }, - ]), - // JWT Authentication module - AuthModule, - // New Redis-based rate limiting module - RateLimitingModule.forRoot(), - // Gas usage CSV export module - ExportsModule, - // Scan module for code analysis - ScanModule, - ], - controllers: [ - AppController, - ExampleController, - FailedTransactionController, - CrossChainGasController, - // Add your controllers here - remember to add @Version('1') decorator - ], - providers: [ - // Apply RateLimitGuard globally for per-API key rate limiting - { - provide: APP_GUARD, - useClass: RateLimitGuard, - }, - // Apply JWT authentication guard globally to all routes - { - provide: APP_GUARD, - useClass: JwtAuthGuard, - }, - // Apply roles guard globally for RBAC enforcement - { - provide: APP_GUARD, - useClass: RolesGuard, - }, - FailedTransactionService, - MitigationService, - TransactionAnalysisService, - CrossChainGasService, - ], + imports: [ + // Global configuration module for environment variables + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: [".env", ".env.local"], + }), + ThrottlerModule.forRoot([ + { + name: "default", + ttl: 60000, // 60 seconds in milliseconds + limit: 100, // 100 requests per TTL window (generous fallback) + }, + ]), + // JWT Authentication module + AuthModule, + // New Redis-based rate limiting module + RateLimitingModule.forRoot(), + // Gas usage CSV export module + ExportsModule, + // Scan module for code analysis + ScanModule, + ], + controllers: [ + AppController, + ExampleController, + FailedTransactionController, + CrossChainGasController, + // Add your controllers here - remember to add @Version('1') decorator + ], + providers: [ + // Apply RateLimitGuard globally for per-API key rate limiting + { + provide: APP_GUARD, + useClass: RateLimitGuard, + }, + // Apply JWT authentication guard globally to all routes + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + // Apply roles guard globally for RBAC enforcement + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + FailedTransactionService, + MitigationService, + TransactionAnalysisService, + CrossChainGasService, + ], }) -export class AppModule { } +export class AppModule {} diff --git a/apps/api/src/auth/__tests__/auth.module.spec.ts b/apps/api/src/auth/__tests__/auth.module.spec.ts index c2d7609..f09f5a5 100644 --- a/apps/api/src/auth/__tests__/auth.module.spec.ts +++ b/apps/api/src/auth/__tests__/auth.module.spec.ts @@ -1,13 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { AuthModule } from '../auth.module'; -import { JwtStrategy } from '../strategies/jwt.strategy'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { RolesGuard } from '../guards/roles.guard'; - -describe('AuthModule', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { AuthModule } from "../auth.module"; +import { JwtStrategy } from "../strategies/jwt.strategy"; +import { JwtAuthGuard } from "../guards/jwt-auth.guard"; +import { RolesGuard } from "../guards/roles.guard"; + +describe("AuthModule", () => { let module: TestingModule; beforeEach(async () => { @@ -15,11 +15,13 @@ describe('AuthModule', () => { imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [() => ({ - JWT_SECRET: 'test-secret-key-that-is-at-least-32-characters-long', - JWT_ISSUER: 'test-issuer', - JWT_AUDIENCE: 'test-audience', - })], + load: [ + () => ({ + JWT_SECRET: "test-secret-key-that-is-at-least-32-characters-long", + JWT_ISSUER: "test-issuer", + JWT_AUDIENCE: "test-audience", + }), + ], }), AuthModule, ], @@ -30,84 +32,86 @@ describe('AuthModule', () => { await module.close(); }); - it('should compile the module', () => { + it("should compile the module", () => { expect(module).toBeDefined(); }); - it('should provide JwtStrategy', () => { + it("should provide JwtStrategy", () => { const strategy = module.get(JwtStrategy); expect(strategy).toBeDefined(); }); - it('should provide JwtAuthGuard', () => { + it("should provide JwtAuthGuard", () => { const guard = module.get(JwtAuthGuard); expect(guard).toBeDefined(); }); - it('should provide RolesGuard', () => { + it("should provide RolesGuard", () => { const guard = module.get(RolesGuard); expect(guard).toBeDefined(); }); - it('should export JwtAuthGuard', async () => { + it("should export JwtAuthGuard", async () => { // Test that JwtAuthGuard can be imported from the module const exportedGuard = module.get(JwtAuthGuard); expect(exportedGuard).toBeInstanceOf(JwtAuthGuard); }); - it('should export RolesGuard', async () => { + it("should export RolesGuard", async () => { const exportedGuard = module.get(RolesGuard); expect(exportedGuard).toBeInstanceOf(RolesGuard); }); - it('should export PassportModule', () => { + it("should export PassportModule", () => { const passportModule = module.get(PassportModule); expect(passportModule).toBeDefined(); }); - it('should export JwtModule', () => { + it("should export JwtModule", () => { const jwtModule = module.get(JwtModule); expect(jwtModule).toBeDefined(); }); - describe('JWT Configuration', () => { - it('should configure JWT with secret from ConfigService', () => { + describe("JWT Configuration", () => { + it("should configure JWT with secret from ConfigService", () => { const configService = module.get(ConfigService); - const secret = configService.get('JWT_SECRET'); - - expect(secret).toBe('test-secret-key-that-is-at-least-32-characters-long'); + const secret = configService.get("JWT_SECRET"); + + expect(secret).toBe( + "test-secret-key-that-is-at-least-32-characters-long", + ); }); - it('should configure JWT with issuer from ConfigService', () => { + it("should configure JWT with issuer from ConfigService", () => { const configService = module.get(ConfigService); - const issuer = configService.get('JWT_ISSUER'); - - expect(issuer).toBe('test-issuer'); + const issuer = configService.get("JWT_ISSUER"); + + expect(issuer).toBe("test-issuer"); }); - it('should configure JWT with audience from ConfigService', () => { + it("should configure JWT with audience from ConfigService", () => { const configService = module.get(ConfigService); - const audience = configService.get('JWT_AUDIENCE'); - - expect(audience).toBe('test-audience'); + const audience = configService.get("JWT_AUDIENCE"); + + expect(audience).toBe("test-audience"); }); }); - describe('Module Metadata', () => { - it('should have PassportModule as import', () => { + describe("Module Metadata", () => { + it("should have PassportModule as import", () => { const passportModule = module.get(PassportModule); expect(passportModule).toBeDefined(); }); - it('should have JwtModule as import', () => { + it("should have JwtModule as import", () => { const jwtModule = module.get(JwtModule); expect(jwtModule).toBeDefined(); }); }); }); -describe('AuthModule without JWT_SECRET', () => { - it('should throw error when JWT_SECRET is not configured', async () => { +describe("AuthModule without JWT_SECRET", () => { + it("should throw error when JWT_SECRET is not configured", async () => { await expect( Test.createTestingModule({ imports: [ @@ -117,7 +121,7 @@ describe('AuthModule without JWT_SECRET', () => { }), AuthModule, ], - }).compile() - ).rejects.toThrow('JWT_SECRET environment variable is required'); + }).compile(), + ).rejects.toThrow("JWT_SECRET environment variable is required"); }); }); diff --git a/apps/api/src/auth/__tests__/decorators.spec.ts b/apps/api/src/auth/__tests__/decorators.spec.ts index db4c758..3936034 100644 --- a/apps/api/src/auth/__tests__/decorators.spec.ts +++ b/apps/api/src/auth/__tests__/decorators.spec.ts @@ -1,56 +1,56 @@ -import { Public, IS_PUBLIC_KEY, Roles, Role, ROLES_KEY } from '../decorators'; -import { CurrentUser } from '../decorators/current-user.decorator'; +import { Public, IS_PUBLIC_KEY, Roles, Role, ROLES_KEY } from "../decorators"; +import { CurrentUser } from "../decorators/current-user.decorator"; -describe('Decorators', () => { - describe('Public decorator', () => { - it('should export IS_PUBLIC_KEY constant', () => { - expect(IS_PUBLIC_KEY).toBe('isPublic'); +describe("Decorators", () => { + describe("Public decorator", () => { + it("should export IS_PUBLIC_KEY constant", () => { + expect(IS_PUBLIC_KEY).toBe("isPublic"); }); - it('should be a function', () => { - expect(typeof Public).toBe('function'); + it("should be a function", () => { + expect(typeof Public).toBe("function"); }); }); - describe('Roles decorator', () => { - it('should export ROLES_KEY constant', () => { - expect(ROLES_KEY).toBe('roles'); + describe("Roles decorator", () => { + it("should export ROLES_KEY constant", () => { + expect(ROLES_KEY).toBe("roles"); }); - it('should be a function', () => { - expect(typeof Roles).toBe('function'); + it("should be a function", () => { + expect(typeof Roles).toBe("function"); }); }); - describe('Role constants', () => { - it('should have ADMIN role', () => { - expect(Role.ADMIN).toBe('admin'); + describe("Role constants", () => { + it("should have ADMIN role", () => { + expect(Role.ADMIN).toBe("admin"); }); - it('should have ANALYST role', () => { - expect(Role.ANALYST).toBe('analyst'); + it("should have ANALYST role", () => { + expect(Role.ANALYST).toBe("analyst"); }); - it('should have USER role', () => { - expect(Role.USER).toBe('user'); + it("should have USER role", () => { + expect(Role.USER).toBe("user"); }); - it('should have READONLY role', () => { - expect(Role.READONLY).toBe('readonly'); + it("should have READONLY role", () => { + expect(Role.READONLY).toBe("readonly"); }); - it('should have correct role values', () => { - expect(Role.ADMIN).toBe('admin'); - expect(Role.ANALYST).toBe('analyst'); - expect(Role.USER).toBe('user'); - expect(Role.READONLY).toBe('readonly'); + it("should have correct role values", () => { + expect(Role.ADMIN).toBe("admin"); + expect(Role.ANALYST).toBe("analyst"); + expect(Role.USER).toBe("user"); + expect(Role.READONLY).toBe("readonly"); }); }); - describe('CurrentUser decorator', () => { - it('should be defined', () => { + describe("CurrentUser decorator", () => { + it("should be defined", () => { expect(CurrentUser).toBeDefined(); - expect(typeof CurrentUser).toBe('function'); + expect(typeof CurrentUser).toBe("function"); }); }); }); diff --git a/apps/api/src/auth/__tests__/index.ts b/apps/api/src/auth/__tests__/index.ts index 2803b2e..60dc1ed 100644 --- a/apps/api/src/auth/__tests__/index.ts +++ b/apps/api/src/auth/__tests__/index.ts @@ -1,6 +1,6 @@ // Auth module test exports -export * from './jwt.strategy.spec'; -export * from './jwt-auth.guard.spec'; -export * from './roles.guard.spec'; -export * from './decorators.spec'; -export * from './auth.module.spec'; +export * from "./jwt.strategy.spec"; +export * from "./jwt-auth.guard.spec"; +export * from "./roles.guard.spec"; +export * from "./decorators.spec"; +export * from "./auth.module.spec"; diff --git a/apps/api/src/auth/__tests__/jwt-auth.guard.spec.ts b/apps/api/src/auth/__tests__/jwt-auth.guard.spec.ts index ffe359f..d38211d 100644 --- a/apps/api/src/auth/__tests__/jwt-auth.guard.spec.ts +++ b/apps/api/src/auth/__tests__/jwt-auth.guard.spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { JwtAuthGuard } from "../guards/jwt-auth.guard"; +import { IS_PUBLIC_KEY } from "../decorators/public.decorator"; -describe('JwtAuthGuard', () => { +describe("JwtAuthGuard", () => { let guard: JwtAuthGuard; let reflector: Reflector; @@ -12,7 +12,9 @@ describe('JwtAuthGuard', () => { getAllAndOverride: jest.fn(), }; - const createMockExecutionContext = (isPublic: boolean = false): ExecutionContext => { + const createMockExecutionContext = ( + isPublic: boolean = false, + ): ExecutionContext => { mockReflector.getAllAndOverride.mockImplementation((key: string) => { if (key === IS_PUBLIC_KEY) return isPublic; return undefined; @@ -47,97 +49,101 @@ describe('JwtAuthGuard', () => { jest.clearAllMocks(); }); - describe('canActivate', () => { - it('should allow access to public routes without authentication', () => { + describe("canActivate", () => { + it("should allow access to public routes without authentication", () => { const context = createMockExecutionContext(true); - + const result = guard.canActivate(context); expect(result).toBe(true); - expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); + expect(mockReflector.getAllAndOverride).toHaveBeenCalledWith( + IS_PUBLIC_KEY, + [context.getHandler(), context.getClass()], + ); }); - it('should require authentication for non-public routes', () => { + it("should require authentication for non-public routes", () => { const context = createMockExecutionContext(false); - + // Mock the parent canActivate to return true (authenticated) - jest.spyOn(guard, 'canActivate').mockReturnValueOnce(true as any); - + jest.spyOn(guard, "canActivate").mockReturnValueOnce(true as any); + const result = guard.canActivate(context); expect(result).toBe(true); }); }); - describe('handleRequest', () => { - it('should return user when authentication is successful', () => { - const user = { userId: 'user-123', roles: ['user'] }; - + describe("handleRequest", () => { + it("should return user when authentication is successful", () => { + const user = { userId: "user-123", roles: ["user"] }; + const result = guard.handleRequest(null, user, null); expect(result).toEqual(user); }); - it('should throw UnauthorizedException when user is null', () => { - expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException); - + it("should throw UnauthorizedException when user is null", () => { + expect(() => guard.handleRequest(null, null, null)).toThrow( + UnauthorizedException, + ); + try { guard.handleRequest(null, null, null); } catch (error) { expect(error).toBeInstanceOf(UnauthorizedException); expect(error.response).toMatchObject({ - error: 'Unauthorized', - message: 'Invalid or expired JWT access token.', + error: "Unauthorized", + message: "Invalid or expired JWT access token.", }); expect(error.response.timestamp).toBeDefined(); } }); - it('should throw UnauthorizedException when error is present', () => { - const error = new Error('Some error'); - - expect(() => guard.handleRequest(error, null, null)).toThrow(UnauthorizedException); + it("should throw UnauthorizedException when error is present", () => { + const error = new Error("Some error"); + + expect(() => guard.handleRequest(error, null, null)).toThrow( + UnauthorizedException, + ); }); - it('should include TokenExpiredError message when token is expired', () => { - const info = { name: 'TokenExpiredError', message: 'jwt expired' }; - + it("should include TokenExpiredError message when token is expired", () => { + const info = { name: "TokenExpiredError", message: "jwt expired" }; + try { guard.handleRequest(null, null, info); } catch (error) { expect(error).toBeInstanceOf(UnauthorizedException); - expect(error.response.message).toBe('JWT access token has expired.'); + expect(error.response.message).toBe("JWT access token has expired."); } }); - it('should include JsonWebTokenError message when token format is invalid', () => { - const info = { name: 'JsonWebTokenError', message: 'invalid token' }; - + it("should include JsonWebTokenError message when token format is invalid", () => { + const info = { name: "JsonWebTokenError", message: "invalid token" }; + try { guard.handleRequest(null, null, info); } catch (error) { expect(error).toBeInstanceOf(UnauthorizedException); - expect(error.response.message).toBe('Invalid JWT access token format.'); + expect(error.response.message).toBe("Invalid JWT access token format."); } }); - it('should use info message when available', () => { - const info = { message: 'Custom error message' }; - + it("should use info message when available", () => { + const info = { message: "Custom error message" }; + try { guard.handleRequest(null, null, info); } catch (error) { expect(error).toBeInstanceOf(UnauthorizedException); - expect(error.response.message).toBe('Custom error message'); + expect(error.response.message).toBe("Custom error message"); } }); - it('should include timestamp in error response', () => { + it("should include timestamp in error response", () => { const beforeTime = new Date().getTime(); - + try { guard.handleRequest(null, null, null); } catch (error: any) { diff --git a/apps/api/src/auth/__tests__/jwt.strategy.spec.ts b/apps/api/src/auth/__tests__/jwt.strategy.spec.ts index 01233b3..8d94df8 100644 --- a/apps/api/src/auth/__tests__/jwt.strategy.spec.ts +++ b/apps/api/src/auth/__tests__/jwt.strategy.spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { UnauthorizedException } from '@nestjs/common'; -import { JwtStrategy, JwtPayload } from '../strategies/jwt.strategy'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { UnauthorizedException } from "@nestjs/common"; +import { JwtStrategy, JwtPayload } from "../strategies/jwt.strategy"; -describe('JwtStrategy', () => { +describe("JwtStrategy", () => { let strategy: JwtStrategy; let configService: ConfigService; @@ -12,14 +12,16 @@ describe('JwtStrategy', () => { }; beforeEach(async () => { - mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => { - const config: Record = { - JWT_SECRET: 'test-secret-key-that-is-at-least-32-characters-long', - JWT_ISSUER: 'test-issuer', - JWT_AUDIENCE: 'test-audience', - }; - return config[key] ?? defaultValue; - }); + mockConfigService.get.mockImplementation( + (key: string, defaultValue?: any) => { + const config: Record = { + JWT_SECRET: "test-secret-key-that-is-at-least-32-characters-long", + JWT_ISSUER: "test-issuer", + JWT_AUDIENCE: "test-audience", + }; + return config[key] ?? defaultValue; + }, + ); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -39,52 +41,55 @@ describe('JwtStrategy', () => { jest.clearAllMocks(); }); - describe('constructor', () => { - it('should throw error if JWT_SECRET is not set', () => { + describe("constructor", () => { + it("should throw error if JWT_SECRET is not set", () => { mockConfigService.get.mockReturnValue(undefined); - + expect(() => { new JwtStrategy(configService); - }).toThrow('JWT_SECRET environment variable is required'); + }).toThrow("JWT_SECRET environment variable is required"); }); - it('should use default issuer and audience if not configured', () => { - mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => { - if (key === 'JWT_SECRET') return 'test-secret-key-that-is-at-least-32-characters-long'; - return defaultValue; - }); + it("should use default issuer and audience if not configured", () => { + mockConfigService.get.mockImplementation( + (key: string, defaultValue?: any) => { + if (key === "JWT_SECRET") + return "test-secret-key-that-is-at-least-32-characters-long"; + return defaultValue; + }, + ); const testStrategy = new JwtStrategy(configService); expect(testStrategy).toBeDefined(); }); }); - describe('validate', () => { + describe("validate", () => { const validPayload: JwtPayload = { - sub: 'user-123', - iss: 'test-issuer', - aud: 'test-audience', + sub: "user-123", + iss: "test-issuer", + aud: "test-audience", exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), - roles: ['user', 'analyst'], - permissions: ['read:transactions', 'write:transactions'], + roles: ["user", "analyst"], + permissions: ["read:transactions", "write:transactions"], }; - it('should validate a valid payload and return user', async () => { + it("should validate a valid payload and return user", async () => { const result = await strategy.validate(validPayload); expect(result).toEqual({ - userId: 'user-123', - roles: ['user', 'analyst'], - permissions: ['read:transactions', 'write:transactions'], + userId: "user-123", + roles: ["user", "analyst"], + permissions: ["read:transactions", "write:transactions"], }); }); - it('should validate payload with empty roles and permissions', async () => { + it("should validate payload with empty roles and permissions", async () => { const payloadWithoutRoles: JwtPayload = { - sub: 'user-123', - iss: 'test-issuer', - aud: 'test-audience', + sub: "user-123", + iss: "test-issuer", + aud: "test-audience", exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), }; @@ -92,63 +97,63 @@ describe('JwtStrategy', () => { const result = await strategy.validate(payloadWithoutRoles); expect(result).toEqual({ - userId: 'user-123', + userId: "user-123", roles: [], permissions: [], }); }); - it('should throw UnauthorizedException if sub claim is missing', async () => { + it("should throw UnauthorizedException if sub claim is missing", async () => { const invalidPayload = { ...validPayload, sub: undefined as any }; await expect(strategy.validate(invalidPayload)).rejects.toThrow( - new UnauthorizedException('Invalid token: missing subject claim') + new UnauthorizedException("Invalid token: missing subject claim"), ); }); - it('should throw UnauthorizedException if iss claim is missing', async () => { + it("should throw UnauthorizedException if iss claim is missing", async () => { const invalidPayload = { ...validPayload, iss: undefined as any }; await expect(strategy.validate(invalidPayload)).rejects.toThrow( - new UnauthorizedException('Invalid token: missing issuer claim') + new UnauthorizedException("Invalid token: missing issuer claim"), ); }); - it('should throw UnauthorizedException if aud claim is missing', async () => { + it("should throw UnauthorizedException if aud claim is missing", async () => { const invalidPayload = { ...validPayload, aud: undefined as any }; await expect(strategy.validate(invalidPayload)).rejects.toThrow( - new UnauthorizedException('Invalid token: missing audience claim') + new UnauthorizedException("Invalid token: missing audience claim"), ); }); - it('should throw UnauthorizedException if exp claim is missing', async () => { + it("should throw UnauthorizedException if exp claim is missing", async () => { const invalidPayload = { ...validPayload, exp: undefined as any }; await expect(strategy.validate(invalidPayload)).rejects.toThrow( - new UnauthorizedException('Invalid token: missing expiration claim') + new UnauthorizedException("Invalid token: missing expiration claim"), ); }); - it('should throw UnauthorizedException if token is expired', async () => { + it("should throw UnauthorizedException if token is expired", async () => { const expiredPayload: JwtPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago }; await expect(strategy.validate(expiredPayload)).rejects.toThrow( - new UnauthorizedException('Token has expired') + new UnauthorizedException("Token has expired"), ); }); - it('should reject token that expired in the past', async () => { + it("should reject token that expired in the past", async () => { const expiredPayload: JwtPayload = { ...validPayload, exp: Math.floor(Date.now() / 1000) - 1, // 1 second ago }; await expect(strategy.validate(expiredPayload)).rejects.toThrow( - new UnauthorizedException('Token has expired') + new UnauthorizedException("Token has expired"), ); }); }); diff --git a/apps/api/src/auth/__tests__/roles.guard.spec.ts b/apps/api/src/auth/__tests__/roles.guard.spec.ts index 817e550..20cf382 100644 --- a/apps/api/src/auth/__tests__/roles.guard.spec.ts +++ b/apps/api/src/auth/__tests__/roles.guard.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { RolesGuard } from '../guards/roles.guard'; -import { ROLES_KEY } from '../decorators/roles.decorator'; -import { JwtUser } from '../strategies/jwt.strategy'; - -describe('RolesGuard', () => { +import { Test, TestingModule } from "@nestjs/testing"; +import { ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { RolesGuard } from "../guards/roles.guard"; +import { ROLES_KEY } from "../decorators/roles.decorator"; +import { JwtUser } from "../strategies/jwt.strategy"; + +describe("RolesGuard", () => { let guard: RolesGuard; let reflector: Reflector; @@ -13,7 +13,10 @@ describe('RolesGuard', () => { getAllAndOverride: jest.fn(), }; - const createMockExecutionContext = (user: JwtUser | null, requiredRoles?: string[]): ExecutionContext => { + const createMockExecutionContext = ( + user: JwtUser | null, + requiredRoles?: string[], + ): ExecutionContext => { mockReflector.getAllAndOverride.mockImplementation((key: string) => { if (key === ROLES_KEY) return requiredRoles; return undefined; @@ -47,11 +50,11 @@ describe('RolesGuard', () => { jest.clearAllMocks(); }); - describe('canActivate', () => { - it('should allow access when no roles are required', () => { + describe("canActivate", () => { + it("should allow access when no roles are required", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: [], permissions: [] }, - [] + { userId: "user-123", roles: [], permissions: [] }, + [], ); const result = guard.canActivate(context); @@ -59,10 +62,10 @@ describe('RolesGuard', () => { expect(result).toBe(true); }); - it('should allow access when roles array is empty', () => { + it("should allow access when roles array is empty", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: [], permissions: [] }, - [] + { userId: "user-123", roles: [], permissions: [] }, + [], ); const result = guard.canActivate(context); @@ -70,10 +73,10 @@ describe('RolesGuard', () => { expect(result).toBe(true); }); - it('should allow access when roles is undefined', () => { + it("should allow access when roles is undefined", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: [], permissions: [] }, - undefined + { userId: "user-123", roles: [], permissions: [] }, + undefined, ); const result = guard.canActivate(context); @@ -81,10 +84,10 @@ describe('RolesGuard', () => { expect(result).toBe(true); }); - it('should allow access when user has one of the required roles', () => { + it("should allow access when user has one of the required roles", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: ['user', 'analyst'], permissions: [] }, - ['admin', 'analyst'] + { userId: "user-123", roles: ["user", "analyst"], permissions: [] }, + ["admin", "analyst"], ); const result = guard.canActivate(context); @@ -92,10 +95,10 @@ describe('RolesGuard', () => { expect(result).toBe(true); }); - it('should allow access when user has exact required role', () => { + it("should allow access when user has exact required role", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: ['admin'], permissions: [] }, - ['admin'] + { userId: "user-123", roles: ["admin"], permissions: [] }, + ["admin"], ); const result = guard.canActivate(context); @@ -103,83 +106,82 @@ describe('RolesGuard', () => { expect(result).toBe(true); }); - it('should deny access when user has none of the required roles', () => { + it("should deny access when user has none of the required roles", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: ['user'], permissions: [] }, - ['admin'] + { userId: "user-123", roles: ["user"], permissions: [] }, + ["admin"], ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); - + try { guard.canActivate(context); } catch (error) { expect(error).toBeInstanceOf(ForbiddenException); expect(error.response).toMatchObject({ - error: 'Forbidden', - message: 'Access denied: Required roles are [admin]', + error: "Forbidden", + message: "Access denied: Required roles are [admin]", }); expect(error.response.timestamp).toBeDefined(); } }); - it('should deny access when user roles array is empty', () => { + it("should deny access when user roles array is empty", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: [], permissions: [] }, - ['admin'] + { userId: "user-123", roles: [], permissions: [] }, + ["admin"], ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); }); - it('should throw ForbiddenException when user is not authenticated', () => { - const context = createMockExecutionContext( - null, - ['admin'] - ); + it("should throw ForbiddenException when user is not authenticated", () => { + const context = createMockExecutionContext(null, ["admin"]); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); - + try { guard.canActivate(context); } catch (error) { expect(error).toBeInstanceOf(ForbiddenException); - expect(error.response.message).toBe('Access denied: User not authenticated'); + expect(error.response.message).toBe( + "Access denied: User not authenticated", + ); } }); - it('should include all required roles in error message', () => { + it("should include all required roles in error message", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: ['user'], permissions: [] }, - ['admin', 'analyst', 'manager'] + { userId: "user-123", roles: ["user"], permissions: [] }, + ["admin", "analyst", "manager"], ); try { guard.canActivate(context); } catch (error) { expect(error.response.message).toBe( - 'Access denied: Required roles are [admin, analyst, manager]' + "Access denied: Required roles are [admin, analyst, manager]", ); } }); - it('should handle user with undefined roles property', () => { + it("should handle user with undefined roles property", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: undefined as any, permissions: [] }, - ['admin'] + { userId: "user-123", roles: undefined as any, permissions: [] }, + ["admin"], ); expect(() => guard.canActivate(context)).toThrow(ForbiddenException); }); - it('should include timestamp in error response', () => { + it("should include timestamp in error response", () => { const context = createMockExecutionContext( - { userId: 'user-123', roles: ['user'], permissions: [] }, - ['admin'] + { userId: "user-123", roles: ["user"], permissions: [] }, + ["admin"], ); const beforeTime = new Date().getTime(); - + try { guard.canActivate(context); } catch (error: any) { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 8fee80e..f0695c5 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,21 +1,24 @@ -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { JwtStrategy } from './strategies/jwt.strategy'; -import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { RolesGuard } from './guards/roles.guard'; +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { JwtStrategy } from "./strategies/jwt.strategy"; +import { JwtAuthGuard } from "./guards/jwt-auth.guard"; +import { RolesGuard } from "./guards/roles.guard"; @Module({ imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), + PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET'), + secret: configService.get("JWT_SECRET"), signOptions: { - issuer: configService.get('JWT_ISSUER', 'gasguard-api'), - audience: configService.get('JWT_AUDIENCE', 'gasguard-client'), + issuer: configService.get("JWT_ISSUER", "gasguard-api"), + audience: configService.get( + "JWT_AUDIENCE", + "gasguard-client", + ), }, }), inject: [ConfigService], diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts index 147a1e8..99c6dbd 100644 --- a/apps/api/src/auth/decorators/current-user.decorator.ts +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -1,18 +1,18 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { JwtUser } from '../strategies/jwt.strategy'; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { JwtUser } from "../strategies/jwt.strategy"; /** * Extracts the current authenticated user from the request. * Returns the full user object or a specific property if key is provided. - * + * * @param key - Optional property key to extract from user object * @returns The user object or the specified property value - * + * * @example * ```typescript * @Get('profile') * getProfile(@CurrentUser() user: JwtUser) { ... } - * + * * @Get('user-id') * getUserId(@CurrentUser('userId') userId: string) { ... } * ``` diff --git a/apps/api/src/auth/decorators/index.ts b/apps/api/src/auth/decorators/index.ts index 85a9f24..0d2938c 100644 --- a/apps/api/src/auth/decorators/index.ts +++ b/apps/api/src/auth/decorators/index.ts @@ -1,3 +1,3 @@ -export { Public, IS_PUBLIC_KEY } from './public.decorator'; -export { Roles, Role, RoleType, ROLES_KEY } from './roles.decorator'; -export { CurrentUser } from './current-user.decorator'; +export { Public, IS_PUBLIC_KEY } from "./public.decorator"; +export { Roles, Role, RoleType, ROLES_KEY } from "./roles.decorator"; +export { CurrentUser } from "./current-user.decorator"; diff --git a/apps/api/src/auth/decorators/public.decorator.ts b/apps/api/src/auth/decorators/public.decorator.ts index e733b75..7f1b303 100644 --- a/apps/api/src/auth/decorators/public.decorator.ts +++ b/apps/api/src/auth/decorators/public.decorator.ts @@ -1,11 +1,11 @@ -import { SetMetadata } from '@nestjs/common'; +import { SetMetadata } from "@nestjs/common"; -export const IS_PUBLIC_KEY = 'isPublic'; +export const IS_PUBLIC_KEY = "isPublic"; /** * Marks a route or controller as public, bypassing JWT authentication. * Use this decorator on endpoints that don't require authentication. - * + * * @example * ```typescript * @Public() diff --git a/apps/api/src/auth/decorators/roles.decorator.ts b/apps/api/src/auth/decorators/roles.decorator.ts index b581e1a..95f10f9 100644 --- a/apps/api/src/auth/decorators/roles.decorator.ts +++ b/apps/api/src/auth/decorators/roles.decorator.ts @@ -1,13 +1,13 @@ -import { SetMetadata } from '@nestjs/common'; +import { SetMetadata } from "@nestjs/common"; -export const ROLES_KEY = 'roles'; +export const ROLES_KEY = "roles"; /** * Defines the roles required to access a route or controller. * Must be used in combination with JwtAuthGuard and RolesGuard. - * + * * @param roles - Array of role names required for access - * + * * @example * ```typescript * @Roles('admin', 'analyst') @@ -21,10 +21,10 @@ export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); * Predefined role constants for consistent role naming across the application. */ export const Role = { - ADMIN: 'admin', - ANALYST: 'analyst', - USER: 'user', - READONLY: 'readonly', + ADMIN: "admin", + ANALYST: "analyst", + USER: "user", + READONLY: "readonly", } as const; export type RoleType = (typeof Role)[keyof typeof Role]; diff --git a/apps/api/src/auth/guards/index.ts b/apps/api/src/auth/guards/index.ts index 79af697..ccfb0e8 100644 --- a/apps/api/src/auth/guards/index.ts +++ b/apps/api/src/auth/guards/index.ts @@ -1,2 +1,2 @@ -export { JwtAuthGuard } from './jwt-auth.guard'; -export { RolesGuard } from './roles.guard'; +export { JwtAuthGuard } from "./jwt-auth.guard"; +export { RolesGuard } from "./roles.guard"; diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts index c7cf63d..95fbf95 100644 --- a/apps/api/src/auth/guards/jwt-auth.guard.ts +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -2,13 +2,13 @@ import { Injectable, ExecutionContext, UnauthorizedException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthGuard } from '@nestjs/passport'; -import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { AuthGuard } from "@nestjs/passport"; +import { IS_PUBLIC_KEY } from "../decorators/public.decorator"; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { +export class JwtAuthGuard extends AuthGuard("jwt") { constructor(private reflector: Reflector) { super(); } @@ -31,18 +31,18 @@ export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(err: any, user: any, info: any) { // Custom error handling if (err || !user) { - let message = 'Invalid or expired JWT access token.'; + let message = "Invalid or expired JWT access token."; - if (info?.name === 'TokenExpiredError') { - message = 'JWT access token has expired.'; - } else if (info?.name === 'JsonWebTokenError') { - message = 'Invalid JWT access token format.'; + if (info?.name === "TokenExpiredError") { + message = "JWT access token has expired."; + } else if (info?.name === "JsonWebTokenError") { + message = "Invalid JWT access token format."; } else if (info?.message) { message = info.message; } throw new UnauthorizedException({ - error: 'Unauthorized', + error: "Unauthorized", message, timestamp: new Date().toISOString(), }); diff --git a/apps/api/src/auth/guards/roles.guard.ts b/apps/api/src/auth/guards/roles.guard.ts index f185ee2..217210d 100644 --- a/apps/api/src/auth/guards/roles.guard.ts +++ b/apps/api/src/auth/guards/roles.guard.ts @@ -3,20 +3,20 @@ import { CanActivate, ExecutionContext, ForbiddenException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { ROLES_KEY } from '../decorators/roles.decorator'; -import { JwtUser } from '../strategies/jwt.strategy'; +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ROLES_KEY } from "../decorators/roles.decorator"; +import { JwtUser } from "../strategies/jwt.strategy"; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); // If no roles are required, allow access if (!requiredRoles || requiredRoles.length === 0) { @@ -27,8 +27,8 @@ export class RolesGuard implements CanActivate { if (!user) { throw new ForbiddenException({ - error: 'Forbidden', - message: 'Access denied: User not authenticated', + error: "Forbidden", + message: "Access denied: User not authenticated", timestamp: new Date().toISOString(), }); } @@ -40,8 +40,8 @@ export class RolesGuard implements CanActivate { if (!hasRole) { throw new ForbiddenException({ - error: 'Forbidden', - message: `Access denied: Required roles are [${requiredRoles.join(', ')}]`, + error: "Forbidden", + message: `Access denied: Required roles are [${requiredRoles.join(", ")}]`, timestamp: new Date().toISOString(), }); } diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts index e0479c9..f2e9fa4 100644 --- a/apps/api/src/auth/index.ts +++ b/apps/api/src/auth/index.ts @@ -1,4 +1,4 @@ -export { AuthModule } from './auth.module'; -export * from './decorators'; -export * from './guards'; -export * from './strategies'; +export { AuthModule } from "./auth.module"; +export * from "./decorators"; +export * from "./guards"; +export * from "./strategies"; diff --git a/apps/api/src/auth/strategies/index.ts b/apps/api/src/auth/strategies/index.ts index 6527ca9..c35be6e 100644 --- a/apps/api/src/auth/strategies/index.ts +++ b/apps/api/src/auth/strategies/index.ts @@ -1 +1 @@ -export { JwtStrategy, JwtPayload, JwtUser } from './jwt.strategy'; +export { JwtStrategy, JwtPayload, JwtUser } from "./jwt.strategy"; diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index 7242f89..5bf5426 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -1,7 +1,7 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; export interface JwtPayload { sub: string; @@ -23,43 +23,45 @@ export interface JwtUser { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private readonly configService: ConfigService) { - const secret = configService.get('JWT_SECRET'); + const secret = configService.get("JWT_SECRET"); if (!secret) { - throw new Error('JWT_SECRET environment variable is required'); + throw new Error("JWT_SECRET environment variable is required"); } super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: secret, - issuer: configService.get('JWT_ISSUER', 'gasguard-api'), - audience: configService.get('JWT_AUDIENCE', 'gasguard-client'), - algorithms: ['HS256'], + issuer: configService.get("JWT_ISSUER", "gasguard-api"), + audience: configService.get("JWT_AUDIENCE", "gasguard-client"), + algorithms: ["HS256"], }); } async validate(payload: JwtPayload): Promise { // Validate required claims if (!payload.sub) { - throw new UnauthorizedException('Invalid token: missing subject claim'); + throw new UnauthorizedException("Invalid token: missing subject claim"); } if (!payload.iss) { - throw new UnauthorizedException('Invalid token: missing issuer claim'); + throw new UnauthorizedException("Invalid token: missing issuer claim"); } if (!payload.aud) { - throw new UnauthorizedException('Invalid token: missing audience claim'); + throw new UnauthorizedException("Invalid token: missing audience claim"); } if (!payload.exp) { - throw new UnauthorizedException('Invalid token: missing expiration claim'); + throw new UnauthorizedException( + "Invalid token: missing expiration claim", + ); } // Check token expiration (passport-jwt also does this, but double-check) const now = Math.floor(Date.now() / 1000); if (payload.exp < now) { - throw new UnauthorizedException('Token has expired'); + throw new UnauthorizedException("Token has expired"); } // Return user object that will be attached to request diff --git a/apps/api/src/common/cache/index.ts b/apps/api/src/common/cache/index.ts index 31fa2e8..4ee3958 100644 --- a/apps/api/src/common/cache/index.ts +++ b/apps/api/src/common/cache/index.ts @@ -1,3 +1,3 @@ -import { CacheService } from '@cache/index'; +import { CacheService } from "@cache/index"; export const cacheService = new CacheService(); diff --git a/apps/api/src/config/validator.ts b/apps/api/src/config/validator.ts index 2983d9c..9beedd9 100644 --- a/apps/api/src/config/validator.ts +++ b/apps/api/src/config/validator.ts @@ -1,20 +1,20 @@ export interface GasGuardConfig { contracts: string[]; - output?: 'json' | 'table'; + output?: "json" | "table"; failOnHigh?: boolean; } export class ConfigValidationError extends Error { constructor(message: string) { super(message); - this.name = 'ConfigValidationError'; + this.name = "ConfigValidationError"; } } export function validateConfig(config: unknown): GasGuardConfig { - if (!config || typeof config !== 'object') { + if (!config || typeof config !== "object") { throw new ConfigValidationError( - 'Invalid config: configuration must be an object', + "Invalid config: configuration must be an object", ); } @@ -28,22 +28,19 @@ export function validateConfig(config: unknown): GasGuardConfig { if (parsed.contracts.length === 0) { throw new ConfigValidationError( - 'Invalid config: at least one contract is required', + "Invalid config: at least one contract is required", ); } for (const contract of parsed.contracts) { - if (typeof contract !== 'string' || contract.trim() === '') { + if (typeof contract !== "string" || contract.trim() === "") { throw new ConfigValidationError( - 'Invalid config: all contract paths must be non-empty strings', + "Invalid config: all contract paths must be non-empty strings", ); } } - if ( - parsed.output && - !['json', 'table'].includes(parsed.output) - ) { + if (parsed.output && !["json", "table"].includes(parsed.output)) { throw new ConfigValidationError( 'Invalid config: "output" must be either "json" or "table"', ); @@ -51,7 +48,7 @@ export function validateConfig(config: unknown): GasGuardConfig { if ( parsed.failOnHigh !== undefined && - typeof parsed.failOnHigh !== 'boolean' + typeof parsed.failOnHigh !== "boolean" ) { throw new ConfigValidationError( 'Invalid config: "failOnHigh" must be a boolean', @@ -59,4 +56,4 @@ export function validateConfig(config: unknown): GasGuardConfig { } return parsed; -} \ No newline at end of file +} diff --git a/apps/api/src/controllers/analysis.controller.ts b/apps/api/src/controllers/analysis.controller.ts index 548012b..ad2b5df 100644 --- a/apps/api/src/controllers/analysis.controller.ts +++ b/apps/api/src/controllers/analysis.controller.ts @@ -1,14 +1,14 @@ -import { Request, Response } from 'express'; -import { Queue } from 'bullmq'; -import { - CodebaseSubmissionRequest, - AnalysisResponse, - AnalysisStatus, +import { Request, Response } from "express"; +import { Queue } from "bullmq"; +import { + CodebaseSubmissionRequest, + AnalysisResponse, + AnalysisStatus, AnalysisResult, - ApiErrorResponse -} from '../schemas/analysis.schema'; -import { AnalysisValidator } from '../validation/analysis.validator'; -import { cacheService } from '../common/cache'; + ApiErrorResponse, +} from "../schemas/analysis.schema"; +import { AnalysisValidator } from "../validation/analysis.validator"; +import { cacheService } from "../common/cache"; export class AnalysisController { constructor(private queue: Queue) {} @@ -16,292 +16,344 @@ export class AnalysisController { async submitCodebase(req: Request, res: Response): Promise { try { const payload = req.body as CodebaseSubmissionRequest; - + // Check cache first if (payload.project.repositoryUrl && payload.project.commitHash) { - const cacheKey = cacheService.generateKey(payload.project.repositoryUrl, payload.project.commitHash); + const cacheKey = cacheService.generateKey( + payload.project.repositoryUrl, + payload.project.commitHash, + ); const cachedResult = await cacheService.get(cacheKey); - + if (cachedResult) { return res.json({ jobId: cachedResult.jobId, - status: 'completed', + status: "completed", submittedAt: new Date().toISOString(), statusUrl: `/analysis/${cachedResult.jobId}/status`, resultUrl: `/analysis/${cachedResult.jobId}/result`, - fromCache: true + fromCache: true, }); } } // Determine if this is a large job const isLarge = this.isLargeSubmission(payload); - + // Add job to queue with appropriate priority and options - const job = await this.queue.add('analyze-codebase', { - payload, - isLarge, - submittedAt: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - }, { - removeOnComplete: false, - removeOnFail: false, - attempts: isLarge ? 3 : 5, - backoff: { - type: 'exponential', - delay: 2000 + const job = await this.queue.add( + "analyze-codebase", + { + payload, + isLarge, + submittedAt: new Date().toISOString(), + requestId: (req.headers["x-request-id"] as string) || "unknown", }, - priority: isLarge ? 10 : 100 - }); + { + removeOnComplete: false, + removeOnFail: false, + attempts: isLarge ? 3 : 5, + backoff: { + type: "exponential", + delay: 2000, + }, + priority: isLarge ? 10 : 100, + }, + ); const response: AnalysisResponse = { jobId: job.id as string, - status: 'queued', + status: "queued", submittedAt: new Date().toISOString(), estimatedDuration: this.estimateAnalysisDuration(payload), statusUrl: `/analysis/${job.id}/status`, - resultUrl: `/analysis/${job.id}/result` + resultUrl: `/analysis/${job.id}/result`, }; res.status(202).json(response); } catch (error) { - console.error('Error submitting codebase:', error); - this.sendErrorResponse(res, 'SUBMISSION_ERROR', 'Failed to submit codebase for analysis', error); + console.error("Error submitting codebase:", error); + this.sendErrorResponse( + res, + "SUBMISSION_ERROR", + "Failed to submit codebase for analysis", + error, + ); } } async getAnalysisStatus(req: Request, res: Response): Promise { try { const jobId = req.params.id; - + if (!jobId) { res.status(400).json({ error: { - code: 'MISSING_JOB_ID', - message: 'Job ID is required', + code: "MISSING_JOB_ID", + message: "Job ID is required", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const job = await this.queue.getJob(jobId); - + if (!job) { res.status(404).json({ error: { - code: 'JOB_NOT_FOUND', + code: "JOB_NOT_FOUND", message: `Analysis job with ID ${jobId} not found`, timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const state = await job.getState(); const progress = job.progress || 0; - + const status: AnalysisStatus = { jobId, status: this.mapJobState(state), - progress: typeof progress === 'number' ? progress : 0, + progress: typeof progress === "number" ? progress : 0, currentStep: job.data?.currentStep, startedAt: job.data?.startedAt, - completedAt: job.finishedOn ? new Date(job.finishedOn).toISOString() : undefined, - error: job.failedReason ? { - code: 'JOB_FAILED', - message: job.failedReason, - timestamp: new Date().toISOString() - } : undefined + completedAt: job.finishedOn + ? new Date(job.finishedOn).toISOString() + : undefined, + error: job.failedReason + ? { + code: "JOB_FAILED", + message: job.failedReason, + timestamp: new Date().toISOString(), + } + : undefined, }; res.json(status); } catch (error) { - console.error('Error getting analysis status:', error); - this.sendErrorResponse(res, 'STATUS_ERROR', 'Failed to retrieve analysis status', error); + console.error("Error getting analysis status:", error); + this.sendErrorResponse( + res, + "STATUS_ERROR", + "Failed to retrieve analysis status", + error, + ); } } async getAnalysisResult(req: Request, res: Response): Promise { try { const jobId = req.params.id; - + if (!jobId) { res.status(400).json({ error: { - code: 'MISSING_JOB_ID', - message: 'Job ID is required', + code: "MISSING_JOB_ID", + message: "Job ID is required", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const job = await this.queue.getJob(jobId); - + if (!job) { res.status(404).json({ error: { - code: 'JOB_NOT_FOUND', + code: "JOB_NOT_FOUND", message: `Analysis job with ID ${jobId} not found`, timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const state = await job.getState(); - - if (state !== 'completed') { + + if (state !== "completed") { const status: AnalysisStatus = { jobId, status: this.mapJobState(state), - progress: typeof job.progress === 'number' ? job.progress : 0, + progress: typeof job.progress === "number" ? job.progress : 0, startedAt: job.data?.startedAt, - completedAt: job.finishedOn ? new Date(job.finishedOn).toISOString() : undefined + completedAt: job.finishedOn + ? new Date(job.finishedOn).toISOString() + : undefined, }; - + res.status(202).json(status); return; } const result = job.returnvalue as AnalysisResult; - + if (!result) { res.status(500).json({ error: { - code: 'NO_RESULT', - message: 'Analysis completed but no result available', + code: "NO_RESULT", + message: "Analysis completed but no result available", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } res.json({ result }); } catch (error) { - console.error('Error getting analysis result:', error); - this.sendErrorResponse(res, 'RESULT_ERROR', 'Failed to retrieve analysis result', error); + console.error("Error getting analysis result:", error); + this.sendErrorResponse( + res, + "RESULT_ERROR", + "Failed to retrieve analysis result", + error, + ); } } async cancelAnalysis(req: Request, res: Response): Promise { try { const jobId = req.params.id; - + if (!jobId) { res.status(400).json({ error: { - code: 'MISSING_JOB_ID', - message: 'Job ID is required', + code: "MISSING_JOB_ID", + message: "Job ID is required", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const job = await this.queue.getJob(jobId); - + if (!job) { res.status(404).json({ error: { - code: 'JOB_NOT_FOUND', + code: "JOB_NOT_FOUND", message: `Analysis job with ID ${jobId} not found`, timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } const state = await job.getState(); - - if (state === 'completed') { + + if (state === "completed") { res.status(400).json({ error: { - code: 'ALREADY_COMPLETED', - message: 'Cannot cancel a completed analysis', + code: "ALREADY_COMPLETED", + message: "Cannot cancel a completed analysis", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); return; } await job.remove(); - + res.json({ - message: 'Analysis cancelled successfully', + message: "Analysis cancelled successfully", jobId, - cancelledAt: new Date().toISOString() + cancelledAt: new Date().toISOString(), }); } catch (error) { - console.error('Error cancelling analysis:', error); - this.sendErrorResponse(res, 'CANCEL_ERROR', 'Failed to cancel analysis', error); + console.error("Error cancelling analysis:", error); + this.sendErrorResponse( + res, + "CANCEL_ERROR", + "Failed to cancel analysis", + error, + ); } } private isLargeSubmission(payload: CodebaseSubmissionRequest): boolean { - const totalSize = payload.files.reduce((sum, file) => sum + (file.size || 0), 0); + const totalSize = payload.files.reduce( + (sum, file) => sum + (file.size || 0), + 0, + ); const fileCount = payload.files.length; - - return totalSize > 5 * 1024 * 1024 || fileCount > 50 || JSON.stringify(payload).length > 200_000; + + return ( + totalSize > 5 * 1024 * 1024 || + fileCount > 50 || + JSON.stringify(payload).length > 200_000 + ); } private estimateAnalysisDuration(payload: CodebaseSubmissionRequest): number { const fileCount = payload.files.length; - const rustFileCount = payload.files.filter(f => f.language === 'rust').length; - const totalLines = payload.files.reduce((sum, file) => sum + (file.content.split('\n').length), 0); - + const rustFileCount = payload.files.filter( + (f) => f.language === "rust", + ).length; + const totalLines = payload.files.reduce( + (sum, file) => sum + file.content.split("\n").length, + 0, + ); + // Base time: 30 seconds let estimatedSeconds = 30; - + // Add time per file estimatedSeconds += fileCount * 2; - + // Add extra time for Rust files (more complex analysis) estimatedSeconds += rustFileCount * 5; - + // Add time per 1000 lines of code estimatedSeconds += Math.ceil(totalLines / 1000) * 10; - + // Cap at 10 minutes return Math.min(estimatedSeconds, 600); } - private mapJobState(state: string): 'queued' | 'processing' | 'completed' | 'failed' { + private mapJobState( + state: string, + ): "queued" | "processing" | "completed" | "failed" { switch (state) { - case 'waiting': - case 'waiting-children': - return 'queued'; - case 'active': - return 'processing'; - case 'completed': - return 'completed'; - case 'failed': - return 'failed'; + case "waiting": + case "waiting-children": + return "queued"; + case "active": + return "processing"; + case "completed": + return "completed"; + case "failed": + return "failed"; default: - return 'queued'; + return "queued"; } } - private sendErrorResponse(res: Response, code: string, message: string, error?: any): void { + private sendErrorResponse( + res: Response, + code: string, + message: string, + error?: any, + ): void { const response: ApiErrorResponse = { error: { code, message, details: error?.message || error, timestamp: new Date().toISOString(), - requestId: res.locals.requestId || 'unknown' - } + requestId: res.locals.requestId || "unknown", + }, }; - + res.status(500).json(response); } } diff --git a/apps/api/src/example/example.controller.ts b/apps/api/src/example/example.controller.ts index d874add..9ef0bf9 100644 --- a/apps/api/src/example/example.controller.ts +++ b/apps/api/src/example/example.controller.ts @@ -1,21 +1,21 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get } from "@nestjs/common"; /** * Example controller demonstrating API versioning - * + * * This controller is accessible at: * - GET /v1/example - * + * * Unversioned requests (e.g., /example) will return 404 */ -@Controller({ path: 'example', version: '1' }) +@Controller({ path: "example", version: "1" }) export class ExampleController { @Get() getExample() { return { - message: 'This is a versioned endpoint', - version: '1', - path: '/v1/example', + message: "This is a versioned endpoint", + version: "1", + path: "/v1/example", }; } } diff --git a/apps/api/src/exports/dto/gas-usage-filter.dto.ts b/apps/api/src/exports/dto/gas-usage-filter.dto.ts index ba396c6..0eaa9ab 100644 --- a/apps/api/src/exports/dto/gas-usage-filter.dto.ts +++ b/apps/api/src/exports/dto/gas-usage-filter.dto.ts @@ -5,62 +5,64 @@ import { IsEthereumAddress, IsIn, IsUrl, -} from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +} from "class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; export class GasUsageFilterDto { @ApiPropertyOptional({ - description: 'Merchant ID to filter by.', - example: 'merchant-42', + description: "Merchant ID to filter by.", + example: "merchant-42", }) @IsOptional() @IsString() merchantId?: string; @ApiPropertyOptional({ - description: 'Wallet address to filter by.', - example: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + description: "Wallet address to filter by.", + example: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", }) @IsOptional() @IsEthereumAddress() wallet?: string; @ApiPropertyOptional({ - description: 'Start of date range (ISO 8601). Defaults to 30 days ago.', - example: '2024-01-01', + description: "Start of date range (ISO 8601). Defaults to 30 days ago.", + example: "2024-01-01", }) @IsOptional() @IsDateString() from?: string; @ApiPropertyOptional({ - description: 'End of date range (ISO 8601). Defaults to now.', - example: '2024-01-31', + description: "End of date range (ISO 8601). Defaults to now.", + example: "2024-01-31", }) @IsOptional() @IsDateString() to?: string; @ApiPropertyOptional({ - description: 'Chain name or numeric chain ID (e.g. "ethereum", "polygon", "1", "137").', - example: 'ethereum', + description: + 'Chain name or numeric chain ID (e.g. "ethereum", "polygon", "1", "137").', + example: "ethereum", }) @IsOptional() @IsString() chain?: string; @ApiPropertyOptional({ - description: 'Transaction type filter.', - enum: ['transfer', 'swap', 'mint', 'burn', 'all'], - example: 'all', + description: "Transaction type filter.", + enum: ["transfer", "swap", "mint", "burn", "all"], + example: "all", }) @IsOptional() - @IsIn(['transfer', 'swap', 'mint', 'burn', 'all']) + @IsIn(["transfer", "swap", "mint", "burn", "all"]) txType?: string; @ApiPropertyOptional({ - description: 'Custom JSON-RPC URL. Falls back to RPC_URL env var if omitted.', - example: 'https://mainnet.infura.io/v3/YOUR_KEY', + description: + "Custom JSON-RPC URL. Falls back to RPC_URL env var if omitted.", + example: "https://mainnet.infura.io/v3/YOUR_KEY", }) @IsOptional() @IsUrl({ require_tld: false }) diff --git a/apps/api/src/exports/exports.controller.ts b/apps/api/src/exports/exports.controller.ts index 47336af..4c7a5e7 100644 --- a/apps/api/src/exports/exports.controller.ts +++ b/apps/api/src/exports/exports.controller.ts @@ -7,8 +7,8 @@ import { HttpCode, HttpStatus, BadRequestException, -} from '@nestjs/common'; -import { Response } from 'express'; +} from "@nestjs/common"; +import { Response } from "express"; import { ApiTags, ApiOperation, @@ -16,65 +16,75 @@ import { ApiBearerAuth, ApiQuery, ApiParam, -} from '@nestjs/swagger'; -import { ExportsService } from './exports.service'; -import { GasUsageFilterDto } from './dto/gas-usage-filter.dto'; -import { Roles, Role } from '../auth/decorators/roles.decorator'; +} from "@nestjs/swagger"; +import { ExportsService } from "./exports.service"; +import { GasUsageFilterDto } from "./dto/gas-usage-filter.dto"; +import { Roles, Role } from "../auth/decorators/roles.decorator"; -@ApiTags('Exports') +@ApiTags("Exports") @ApiBearerAuth() -@Controller('exports') +@Controller("exports") export class ExportsController { constructor(private readonly exportsService: ExportsService) {} // ─── GET /exports/gas-usage ─────────────────────────────────────────────── - @Get('gas-usage') + @Get("gas-usage") @HttpCode(HttpStatus.OK) @Roles(Role.USER, Role.ADMIN) @ApiOperation({ - summary: 'Export gas usage data as a CSV file', + summary: "Export gas usage data as a CSV file", description: - 'Generates a streamed CSV containing per-transaction gas usage records for a ' + - 'merchant or wallet, optionally filtered by date range, chain, or transaction type. ' + - 'Suitable for direct download by finance teams.', + "Generates a streamed CSV containing per-transaction gas usage records for a " + + "merchant or wallet, optionally filtered by date range, chain, or transaction type. " + + "Suitable for direct download by finance teams.", }) - @ApiQuery({ name: 'merchantId', required: false, description: 'Merchant ID' }) - @ApiQuery({ name: 'wallet', required: false, description: 'Wallet address (0x…)' }) + @ApiQuery({ name: "merchantId", required: false, description: "Merchant ID" }) @ApiQuery({ - name: 'from', + name: "wallet", required: false, - description: 'Start date (ISO 8601). Defaults to 30 days ago.', - example: '2024-01-01', + description: "Wallet address (0x…)", }) @ApiQuery({ - name: 'to', + name: "from", required: false, - description: 'End date (ISO 8601). Defaults to now.', - example: '2024-01-31', + description: "Start date (ISO 8601). Defaults to 30 days ago.", + example: "2024-01-01", }) @ApiQuery({ - name: 'chain', + name: "to", required: false, - description: 'Chain name or numeric chain ID (e.g. "ethereum", "polygon", "1", "137").', + description: "End date (ISO 8601). Defaults to now.", + example: "2024-01-31", }) @ApiQuery({ - name: 'txType', + name: "chain", required: false, - enum: ['transfer', 'swap', 'mint', 'burn', 'all'], - description: 'Transaction type filter.', + description: + 'Chain name or numeric chain ID (e.g. "ethereum", "polygon", "1", "137").', + }) + @ApiQuery({ + name: "txType", + required: false, + enum: ["transfer", "swap", "mint", "burn", "all"], + description: "Transaction type filter.", }) @ApiResponse({ status: 200, - description: 'text/csv attachment.', + description: "text/csv attachment.", headers: { - 'Content-Disposition': { description: 'attachment; filename="gas-usage_….csv"' }, - 'X-Total-Records': { description: 'Number of rows in the export.' }, - 'X-Generated-At': { description: 'ISO timestamp of report generation.' }, + "Content-Disposition": { + description: 'attachment; filename="gas-usage_….csv"', + }, + "X-Total-Records": { description: "Number of rows in the export." }, + "X-Generated-At": { description: "ISO timestamp of report generation." }, }, }) - @ApiResponse({ status: 400, description: 'Invalid filters or no data found.' }) - @ApiResponse({ status: 401, description: 'Unauthorized.' }) + @ApiResponse({ + status: 400, + description: "Invalid filters or no data found.", + }) + @ApiResponse({ status: 401, description: "Unauthorized." }) async exportGasUsage( @Query() filters: GasUsageFilterDto, @Res() res: Response, @@ -85,79 +95,81 @@ export class ExportsController { ); } - const { stream, metadata } = await this.exportsService.generateGasUsageStream(filters); + const { stream, metadata } = + await this.exportsService.generateGasUsageStream(filters); - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${this.buildFilename(filters)}"`, ); - res.setHeader('X-Total-Records', String(metadata.totalRecords)); - res.setHeader('X-Generated-At', metadata.generatedAt); + res.setHeader("X-Total-Records", String(metadata.totalRecords)); + res.setHeader("X-Generated-At", metadata.generatedAt); stream.pipe(res); } // ─── GET /exports/gas-usage/:wallet/download ────────────────────────────── - @Get('gas-usage/:wallet/download') + @Get("gas-usage/:wallet/download") @HttpCode(HttpStatus.OK) @Roles(Role.USER, Role.ADMIN) @ApiOperation({ - summary: 'Download gas usage CSV for a specific wallet', + summary: "Download gas usage CSV for a specific wallet", description: - 'Convenience endpoint that accepts the wallet address as a route parameter. ' + - 'All query-level filters (from, to, chain, txType) are still supported.', + "Convenience endpoint that accepts the wallet address as a route parameter. " + + "All query-level filters (from, to, chain, txType) are still supported.", }) @ApiParam({ - name: 'wallet', - description: 'Wallet address (0x…)', - example: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + name: "wallet", + description: "Wallet address (0x…)", + example: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", }) @ApiQuery({ - name: 'from', + name: "from", required: false, - description: 'Start date (ISO 8601). Defaults to 30 days ago.', - example: '2024-01-01', + description: "Start date (ISO 8601). Defaults to 30 days ago.", + example: "2024-01-01", }) @ApiQuery({ - name: 'to', + name: "to", required: false, - description: 'End date (ISO 8601). Defaults to now.', - example: '2024-01-31', + description: "End date (ISO 8601). Defaults to now.", + example: "2024-01-31", }) @ApiQuery({ - name: 'chain', + name: "chain", required: false, - description: 'Chain name or numeric chain ID.', + description: "Chain name or numeric chain ID.", }) @ApiQuery({ - name: 'txType', + name: "txType", required: false, - enum: ['transfer', 'swap', 'mint', 'burn', 'all'], + enum: ["transfer", "swap", "mint", "burn", "all"], + }) + @ApiResponse({ status: 200, description: "text/csv attachment." }) + @ApiResponse({ + status: 400, + description: "Invalid wallet address or no data found.", }) - @ApiResponse({ status: 200, description: 'text/csv attachment.' }) - @ApiResponse({ status: 400, description: 'Invalid wallet address or no data found.' }) - @ApiResponse({ status: 401, description: 'Unauthorized.' }) + @ApiResponse({ status: 401, description: "Unauthorized." }) async downloadWalletGasUsage( - @Param('wallet') wallet: string, + @Param("wallet") wallet: string, @Query() filters: GasUsageFilterDto, @Res() res: Response, ): Promise { - const { stream, metadata } = await this.exportsService.generateGasUsageStream( - filters, - wallet, - ); + const { stream, metadata } = + await this.exportsService.generateGasUsageStream(filters, wallet); const mergedFilters = { ...filters, wallet }; - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${this.buildFilename(mergedFilters)}"`, ); - res.setHeader('X-Total-Records', String(metadata.totalRecords)); - res.setHeader('X-Generated-At', metadata.generatedAt); + res.setHeader("X-Total-Records", String(metadata.totalRecords)); + res.setHeader("X-Generated-At", metadata.generatedAt); stream.pipe(res); } @@ -165,13 +177,13 @@ export class ExportsController { // ─── Helpers ────────────────────────────────────────────────────────────── private buildFilename(filters: Partial): string { - const parts: string[] = ['gas-usage']; + const parts: string[] = ["gas-usage"]; if (filters.merchantId) parts.push(`merchant-${filters.merchantId}`); if (filters.wallet) parts.push(filters.wallet.slice(0, 10)); if (filters.chain) parts.push(filters.chain.toLowerCase()); if (filters.from) parts.push(filters.from.slice(0, 10)); if (filters.to) parts.push(filters.to.slice(0, 10)); parts.push(new Date().toISOString().slice(0, 10)); - return `${parts.join('_')}.csv`; + return `${parts.join("_")}.csv`; } } diff --git a/apps/api/src/exports/exports.module.ts b/apps/api/src/exports/exports.module.ts index ececc1f..0f7fe83 100644 --- a/apps/api/src/exports/exports.module.ts +++ b/apps/api/src/exports/exports.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { ExportsController } from './exports.controller'; -import { ExportsService } from './exports.service'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { ExportsController } from "./exports.controller"; +import { ExportsService } from "./exports.service"; @Module({ imports: [ConfigModule], diff --git a/apps/api/src/exports/exports.service.ts b/apps/api/src/exports/exports.service.ts index be20f8c..a4a7ab6 100644 --- a/apps/api/src/exports/exports.service.ts +++ b/apps/api/src/exports/exports.service.ts @@ -1,13 +1,13 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ethers } from 'ethers'; -import { PassThrough } from 'stream'; -import { GasUsageFilterDto } from './dto/gas-usage-filter.dto'; +import { Injectable, Logger, BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ethers } from "ethers"; +import { PassThrough } from "stream"; +import { GasUsageFilterDto } from "./dto/gas-usage-filter.dto"; import { GasUsageRecord, ExportMetadata, CSV_HEADERS, -} from './interfaces/gas-export.interface'; +} from "./interfaces/gas-export.interface"; @Injectable() export class ExportsService { @@ -15,30 +15,31 @@ export class ExportsService { /** Maps numeric chain ID → human-readable name. */ private readonly CHAIN_NAMES: Record = { - 1: 'Ethereum', - 5: 'Goerli', - 11155111: 'Sepolia', - 137: 'Polygon', - 80001: 'Mumbai', - 42161: 'Arbitrum One', - 421613: 'Arbitrum Goerli', - 10: 'Optimism', - 420: 'Optimism Goerli', - 56: 'BNB Chain', - 97: 'BNB Testnet', - 43114: 'Avalanche', - 250: 'Fantom', - 8453: 'Base', - 100: 'Gnosis', + 1: "Ethereum", + 5: "Goerli", + 11155111: "Sepolia", + 137: "Polygon", + 80001: "Mumbai", + 42161: "Arbitrum One", + 421613: "Arbitrum Goerli", + 10: "Optimism", + 420: "Optimism Goerli", + 56: "BNB Chain", + 97: "BNB Testnet", + 43114: "Avalanche", + 250: "Fantom", + 8453: "Base", + 100: "Gnosis", }; /** Reverse lookup: normalised name → chain ID. */ - private readonly CHAIN_IDS_BY_NAME: Record = Object.fromEntries( - Object.entries(this.CHAIN_NAMES).map(([id, name]) => [ - name.toLowerCase().replace(/\s+/g, '_'), - Number(id), - ]), - ); + private readonly CHAIN_IDS_BY_NAME: Record = + Object.fromEntries( + Object.entries(this.CHAIN_NAMES).map(([id, name]) => [ + name.toLowerCase().replace(/\s+/g, "_"), + Number(id), + ]), + ); /** Approximate average block times (seconds) per chain for timestamp → block estimation. */ private readonly AVG_BLOCK_TIME: Record = { @@ -75,7 +76,7 @@ export class ExportsService { const wallet = walletOverride ?? filters.wallet; if (!wallet) { throw new BadRequestException( - 'A wallet address is required. Provide it as a query param or route segment.', + "A wallet address is required. Provide it as a query param or route segment.", ); } @@ -114,12 +115,15 @@ export class ExportsService { if (!records.length) { throw new BadRequestException( - 'No transactions found for the given filters. ' + - 'Try widening the date range or verifying the wallet address.', + "No transactions found for the given filters. " + + "Try widening the date range or verifying the wallet address.", ); } - const totalNative = records.reduce((s, r) => s + parseFloat(r.gasCostNative), 0); + const totalNative = records.reduce( + (s, r) => s + parseFloat(r.gasCostNative), + 0, + ); const totalUsd = records.reduce((s, r) => s + parseFloat(r.gasCostUSD), 0); const metadata: ExportMetadata = { @@ -147,7 +151,10 @@ export class ExportsService { * Writes metadata comments, the header row, then all data rows into a * PassThrough stream. Respects backpressure via drain events. */ - private buildCsvStream(records: GasUsageRecord[], metadata: ExportMetadata): PassThrough { + private buildCsvStream( + records: GasUsageRecord[], + metadata: ExportMetadata, + ): PassThrough { const pass = new PassThrough(); setImmediate(async () => { @@ -156,7 +163,9 @@ export class ExportsService { pass.write(`# Gas Usage Export Report\n`); pass.write(`# Generated: ${metadata.generatedAt}\n`); pass.write(`# Total Records: ${metadata.totalRecords}\n`); - pass.write(`# Total Gas Cost (Native): ${metadata.totalGasCostNative}\n`); + pass.write( + `# Total Gas Cost (Native): ${metadata.totalGasCostNative}\n`, + ); pass.write(`# Total Gas Cost (USD): $${metadata.totalGasCostUSD}\n`); if (metadata.filters.merchantId) @@ -165,7 +174,7 @@ export class ExportsService { pass.write(`# Wallet: ${metadata.filters.wallet}\n`); if (metadata.filters.from || metadata.filters.to) pass.write( - `# Date Range: ${metadata.filters.from ?? 'N/A'} → ${metadata.filters.to ?? 'N/A'}\n`, + `# Date Range: ${metadata.filters.from ?? "N/A"} → ${metadata.filters.to ?? "N/A"}\n`, ); if (metadata.filters.chain) pass.write(`# Chain: ${metadata.filters.chain}\n`); @@ -175,18 +184,18 @@ export class ExportsService { pass.write(`\n`); // ── Header row ────────────────────────────────────────────────────── - pass.write(CSV_HEADERS.join(',') + '\n'); + pass.write(CSV_HEADERS.join(",") + "\n"); // ── Data rows (chunked to respect backpressure) ───────────────────── for (let i = 0; i < records.length; i += this.CSV_WRITE_CHUNK) { const lines = records .slice(i, i + this.CSV_WRITE_CHUNK) .map((r) => this.recordToCsvRow(r)) - .join('\n'); + .join("\n"); - const canContinue = pass.write(lines + '\n'); + const canContinue = pass.write(lines + "\n"); if (!canContinue) { - await new Promise((resolve) => pass.once('drain', resolve)); + await new Promise((resolve) => pass.once("drain", resolve)); } } @@ -217,12 +226,12 @@ export class ExportsService { this.escapeCsv(r.gasCostUSD), this.escapeCsv(r.timestamp), r.blockNumber, - ].join(','); + ].join(","); } private escapeCsv(value: string | number): string { - const str = String(value ?? ''); - return str.includes(',') || str.includes('"') || str.includes('\n') + const str = String(value ?? ""); + return str.includes(",") || str.includes('"') || str.includes("\n") ? `"${str.replace(/"/g, '""')}"` : str; } @@ -241,7 +250,12 @@ export class ExportsService { toTimestamp: number, nativeUsdPrice: number, ): Promise { - const txHashes = await this.collectWalletTxHashes(provider, wallet, fromBlock, toBlock); + const txHashes = await this.collectWalletTxHashes( + provider, + wallet, + fromBlock, + toBlock, + ); if (!txHashes.length) return []; const records: GasUsageRecord[] = []; @@ -265,7 +279,7 @@ export class ExportsService { ); for (const result of settled) { - if (result.status === 'fulfilled' && result.value) { + if (result.status === "fulfilled" && result.value) { records.push(result.value); } } @@ -273,7 +287,8 @@ export class ExportsService { // Sort chronologically (oldest → newest) for finance teams return records.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); } @@ -289,7 +304,11 @@ export class ExportsService { ): Promise { const hashes = new Set(); - for (let start = fromBlock; start <= toBlock; start += this.LOG_CHUNK_SIZE) { + for ( + let start = fromBlock; + start <= toBlock; + start += this.LOG_CHUNK_SIZE + ) { const end = Math.min(toBlock, start + this.LOG_CHUNK_SIZE - 1); try { const logs = await provider.getLogs({ fromBlock: start, toBlock: end }); @@ -336,15 +355,19 @@ export class ExportsService { const gasUsed = Number(receipt.gasUsed); const gasPrice = receipt.gasPrice ?? tx.gasPrice ?? 0n; - const gasPriceGwei = parseFloat(ethers.formatUnits(gasPrice, 'gwei')).toFixed(4); + const gasPriceGwei = parseFloat( + ethers.formatUnits(gasPrice, "gwei"), + ).toFixed(4); const gasCostNative = parseFloat( ethers.formatEther(receipt.gasUsed * gasPrice), ).toFixed(8); - const gasCostUSD = (parseFloat(gasCostNative) * nativeUsdPrice).toFixed(6); + const gasCostUSD = (parseFloat(gasCostNative) * nativeUsdPrice).toFixed( + 6, + ); - const inputData = tx.data ?? '0x'; + const inputData = tx.data ?? "0x"; const functionSelector = - inputData.length >= 10 ? inputData.slice(0, 10).toLowerCase() : '0x'; + inputData.length >= 10 ? inputData.slice(0, 10).toLowerCase() : "0x"; return { merchantId, @@ -371,12 +394,12 @@ export class ExportsService { private buildProvider(rpcUrl?: string): ethers.JsonRpcProvider { const url = rpcUrl ?? - this.configService.get('RPC_URL') ?? - this.configService.get('ETHEREUM_RPC_URL'); + this.configService.get("RPC_URL") ?? + this.configService.get("ETHEREUM_RPC_URL"); if (!url) { throw new BadRequestException( - 'No RPC URL configured. Provide rpcUrl in the request or set RPC_URL in .env.', + "No RPC URL configured. Provide rpcUrl in the request or set RPC_URL in .env.", ); } @@ -406,7 +429,8 @@ export class ExportsService { ? Math.floor(new Date(from).getTime() / 1000) : toTimestamp - this.DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60; - const avgBlockTime = this.AVG_BLOCK_TIME[chainId] ?? this.DEFAULT_AVG_BLOCK_TIME; + const avgBlockTime = + this.AVG_BLOCK_TIME[chainId] ?? this.DEFAULT_AVG_BLOCK_TIME; const blocksBack = Math.ceil((toTimestamp - fromTimestamp) / avgBlockTime); return { @@ -421,7 +445,9 @@ export class ExportsService { if (!chain) return null; const asNumber = parseInt(chain, 10); if (!isNaN(asNumber)) return asNumber; - return this.CHAIN_IDS_BY_NAME[chain.toLowerCase().replace(/\s+/g, '_')] ?? null; + return ( + this.CHAIN_IDS_BY_NAME[chain.toLowerCase().replace(/\s+/g, "_")] ?? null + ); } /** @@ -430,22 +456,22 @@ export class ExportsService { */ private async fetchNativeTokenUsdPrice(chainId: number): Promise { const coinGeckoIds: Record = { - 1: 'ethereum', - 5: 'ethereum', - 11155111: 'ethereum', - 137: 'matic-network', - 80001: 'matic-network', - 42161: 'ethereum', - 10: 'ethereum', - 56: 'binancecoin', - 97: 'binancecoin', - 43114: 'avalanche-2', - 250: 'fantom', - 8453: 'ethereum', - 100: 'xdai', + 1: "ethereum", + 5: "ethereum", + 11155111: "ethereum", + 137: "matic-network", + 80001: "matic-network", + 42161: "ethereum", + 10: "ethereum", + 56: "binancecoin", + 97: "binancecoin", + 43114: "avalanche-2", + 250: "fantom", + 8453: "ethereum", + 100: "xdai", }; - const coinId = coinGeckoIds[chainId] ?? 'ethereum'; + const coinId = coinGeckoIds[chainId] ?? "ethereum"; try { const res = await fetch( @@ -463,6 +489,6 @@ export class ExportsService { } private fallbackUsdPrice(): number { - return this.configService.get('ETH_USD_PRICE') ?? 2500; + return this.configService.get("ETH_USD_PRICE") ?? 2500; } } diff --git a/apps/api/src/exports/interfaces/gas-export.interface.ts b/apps/api/src/exports/interfaces/gas-export.interface.ts index f0ae4b2..f09c85d 100644 --- a/apps/api/src/exports/interfaces/gas-export.interface.ts +++ b/apps/api/src/exports/interfaces/gas-export.interface.ts @@ -42,18 +42,18 @@ export interface ExportMetadata { * Column header order – must stay in sync with ExportsService.recordToCsvRow. */ export const CSV_HEADERS = [ - 'MerchantID', - 'Wallet', - 'Chain', - 'ChainID', - 'TxHash', - 'FunctionSelector', - 'GasUsed', - 'GasPriceGwei', - 'GasCostNative', - 'GasCostUSD', - 'Timestamp', - 'BlockNumber', + "MerchantID", + "Wallet", + "Chain", + "ChainID", + "TxHash", + "FunctionSelector", + "GasUsed", + "GasPriceGwei", + "GasCostNative", + "GasCostUSD", + "Timestamp", + "BlockNumber", ] as const; export type CsvHeader = (typeof CSV_HEADERS)[number]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4f9458f..8bf9c6b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,6 +1,6 @@ -import { createServer } from './server.js' +import { createServer } from "./server.js"; -const port = Number(process.env.PORT || 3000) -const app = createServer({} as any) // Pass empty queue for now +const port = Number(process.env.PORT || 3000); +const app = createServer({} as any); // Pass empty queue for now -app.listen(port, () => {}) \ No newline at end of file +app.listen(port, () => {}); diff --git a/apps/api/src/integrations/discord.provider.ts b/apps/api/src/integrations/discord.provider.ts index efdf27c..3e175cf 100644 --- a/apps/api/src/integrations/discord.provider.ts +++ b/apps/api/src/integrations/discord.provider.ts @@ -10,8 +10,8 @@ export class DiscordProvider { scan.severity === "critical" ? 16711680 : scan.severity === "warning" - ? 16753920 - : 3447003; + ? 16753920 + : 3447003; const message = { embeds: [ @@ -33,4 +33,4 @@ export class DiscordProvider { body: JSON.stringify(message), }); } -} \ No newline at end of file +} diff --git a/apps/api/src/integrations/notifier.service.ts b/apps/api/src/integrations/notifier.service.ts index bda04eb..e23a035 100644 --- a/apps/api/src/integrations/notifier.service.ts +++ b/apps/api/src/integrations/notifier.service.ts @@ -32,4 +32,4 @@ export class NotifierService { await Promise.allSettled(tasks); } -} \ No newline at end of file +} diff --git a/apps/api/src/integrations/slack.provider.ts b/apps/api/src/integrations/slack.provider.ts index b343867..025d85b 100644 --- a/apps/api/src/integrations/slack.provider.ts +++ b/apps/api/src/integrations/slack.provider.ts @@ -10,8 +10,8 @@ export class SlackProvider { scan.severity === "critical" ? "#ff0000" : scan.severity === "warning" - ? "#ffa500" - : "#36a64f"; + ? "#ffa500" + : "#36a64f"; const message = { attachments: [ @@ -31,4 +31,4 @@ export class SlackProvider { body: JSON.stringify(message), }); } -} \ No newline at end of file +} diff --git a/apps/api/src/integrations/types.ts b/apps/api/src/integrations/types.ts index d2122fd..5e2ba1a 100644 --- a/apps/api/src/integrations/types.ts +++ b/apps/api/src/integrations/types.ts @@ -11,4 +11,4 @@ export type ScanResult = { export type NotificationPayload = { scan: ScanResult; source?: string; -}; \ No newline at end of file +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index c40e783..3b2c9fe 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,6 @@ -import { NestFactory } from '@nestjs/core'; -import { VersioningType } from '@nestjs/common'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { VersioningType } from "@nestjs/common"; +import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/apps/api/src/middleware/error.middleware.ts b/apps/api/src/middleware/error.middleware.ts index 9ac4c43..af8e31c 100644 --- a/apps/api/src/middleware/error.middleware.ts +++ b/apps/api/src/middleware/error.middleware.ts @@ -1,5 +1,5 @@ -import { Request, Response, NextFunction } from 'express'; -import { ApiErrorResponse } from '../schemas/analysis.schema'; +import { Request, Response, NextFunction } from "express"; +import { ApiErrorResponse } from "../schemas/analysis.schema"; export interface CustomError extends Error { statusCode?: number; @@ -11,35 +11,35 @@ export function errorHandler( error: CustomError, req: Request, res: Response, - next: NextFunction + next: NextFunction, ): void { - console.error('Error occurred:', { + console.error("Error occurred:", { message: error.message, stack: error.stack, url: req.url, method: req.method, - requestId: req.headers['x-request-id'], - timestamp: new Date().toISOString() + requestId: req.headers["x-request-id"], + timestamp: new Date().toISOString(), }); const statusCode = error.statusCode || 500; - const errorCode = error.code || 'INTERNAL_SERVER_ERROR'; - + const errorCode = error.code || "INTERNAL_SERVER_ERROR"; + const response: ApiErrorResponse = { error: { code: errorCode, - message: error.message || 'An unexpected error occurred', + message: error.message || "An unexpected error occurred", details: error.details, timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }; // Don't expose stack trace in production - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== "production") { response.error.details = { ...response.error.details, - stack: error.stack + stack: error.stack, }; } @@ -49,16 +49,21 @@ export function errorHandler( export function notFoundHandler(req: Request, res: Response): void { res.status(404).json({ error: { - code: 'NOT_FOUND', + code: "NOT_FOUND", message: `Route ${req.method} ${req.path} not found`, timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); } -export function requestIdHandler(req: Request, res: Response, next: NextFunction): void { - req.headers['x-request-id'] = req.headers['x-request-id'] || +export function requestIdHandler( + req: Request, + res: Response, + next: NextFunction, +): void { + req.headers["x-request-id"] = + req.headers["x-request-id"] || `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; next(); } @@ -66,10 +71,10 @@ export function requestIdHandler(req: Request, res: Response, next: NextFunction export function rateLimitHandler(req: Request, res: Response): void { res.status(429).json({ error: { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Too many requests, please try again later', + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests, please try again later", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); } diff --git a/apps/api/src/modules/scan/dto/scan.dto.ts b/apps/api/src/modules/scan/dto/scan.dto.ts index f4c9947..a4bd872 100644 --- a/apps/api/src/modules/scan/dto/scan.dto.ts +++ b/apps/api/src/modules/scan/dto/scan.dto.ts @@ -1,59 +1,77 @@ -import { IsString, IsOptional, IsArray, IsEnum, IsObject, Min, Max } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsArray, + IsEnum, + IsObject, + Min, + Max, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export enum ScanType { - SECURITY = 'security', - GAS = 'gas', - PERFORMANCE = 'performance', - FULL = 'full' + SECURITY = "security", + GAS = "gas", + PERFORMANCE = "performance", + FULL = "full", } export enum ScanStatus { - PENDING = 'pending', - RUNNING = 'running', - COMPLETED = 'completed', - FAILED = 'failed', - CANCELLED = 'cancelled' + PENDING = "pending", + RUNNING = "running", + COMPLETED = "completed", + FAILED = "failed", + CANCELLED = "cancelled", } export class ScanRequestDto { - @ApiProperty({ description: 'Source code to scan' }) + @ApiProperty({ description: "Source code to scan" }) @IsString() code: string; - @ApiPropertyOptional({ description: 'Language of the code', enum: ['solidity', 'vyper', 'rust', 'javascript', 'typescript'] }) + @ApiPropertyOptional({ + description: "Language of the code", + enum: ["solidity", "vyper", "rust", "javascript", "typescript"], + }) @IsOptional() @IsString() language?: string; - @ApiPropertyOptional({ description: 'Type of scan to perform', enum: ScanType }) + @ApiPropertyOptional({ + description: "Type of scan to perform", + enum: ScanType, + }) @IsOptional() @IsEnum(ScanType) scanType?: ScanType = ScanType.FULL; - @ApiPropertyOptional({ description: 'File path for context' }) + @ApiPropertyOptional({ description: "File path for context" }) @IsOptional() @IsString() filePath?: string; - @ApiPropertyOptional({ description: 'Specific rules to run' }) + @ApiPropertyOptional({ description: "Specific rules to run" }) @IsOptional() @IsArray() @IsString({ each: true }) rules?: string[]; - @ApiPropertyOptional({ description: 'Custom configuration' }) + @ApiPropertyOptional({ description: "Custom configuration" }) @IsOptional() @IsObject() config?: Record; - @ApiPropertyOptional({ description: 'Severity threshold', minimum: 0, maximum: 100 }) + @ApiPropertyOptional({ + description: "Severity threshold", + minimum: 0, + maximum: 100, + }) @IsOptional() @Min(0) @Max(100) severityThreshold?: number; - @ApiPropertyOptional({ description: 'Maximum number of findings to return' }) + @ApiPropertyOptional({ description: "Maximum number of findings to return" }) @IsOptional() @Min(1) @Max(1000) @@ -92,86 +110,86 @@ export interface SummaryDto { } export class ScanResponseDto { - @ApiProperty({ description: 'Unique scan identifier' }) + @ApiProperty({ description: "Unique scan identifier" }) scanId: string; - @ApiProperty({ description: 'Scan status', enum: ScanStatus }) + @ApiProperty({ description: "Scan status", enum: ScanStatus }) status: ScanStatus; - @ApiProperty({ description: 'Array of findings' }) + @ApiProperty({ description: "Array of findings" }) findings: FindingDto[]; - @ApiProperty({ description: 'Analysis summary' }) + @ApiProperty({ description: "Analysis summary" }) summary: SummaryDto; - @ApiProperty({ description: 'Analysis time in milliseconds' }) + @ApiProperty({ description: "Analysis time in milliseconds" }) analysisTime: number; - @ApiProperty({ description: 'Files analyzed' }) + @ApiProperty({ description: "Files analyzed" }) filesAnalyzed: number; - @ApiPropertyOptional({ description: 'Total estimated gas savings' }) + @ApiPropertyOptional({ description: "Total estimated gas savings" }) totalEstimatedGasSavings?: number; - @ApiPropertyOptional({ description: 'Analyzer version' }) + @ApiPropertyOptional({ description: "Analyzer version" }) analyzerVersion?: string; - @ApiPropertyOptional({ description: 'Errors during analysis' }) + @ApiPropertyOptional({ description: "Errors during analysis" }) errors?: Array<{ file: string; message: string; error?: string; }>; - @ApiProperty({ description: 'Timestamp when scan was completed' }) + @ApiProperty({ description: "Timestamp when scan was completed" }) timestamp: string; } export class ScanStatusDto { - @ApiProperty({ description: 'Unique scan identifier' }) + @ApiProperty({ description: "Unique scan identifier" }) scanId: string; - @ApiProperty({ description: 'Current scan status', enum: ScanStatus }) + @ApiProperty({ description: "Current scan status", enum: ScanStatus }) status: ScanStatus; - @ApiPropertyOptional({ description: 'Progress percentage (0-100)' }) + @ApiPropertyOptional({ description: "Progress percentage (0-100)" }) @IsOptional() progress?: number; - @ApiPropertyOptional({ description: 'Current operation being performed' }) + @ApiPropertyOptional({ description: "Current operation being performed" }) @IsOptional() currentOperation?: string; - @ApiPropertyOptional({ description: 'Estimated time remaining in seconds' }) + @ApiPropertyOptional({ description: "Estimated time remaining in seconds" }) @IsOptional() estimatedTimeRemaining?: number; - @ApiProperty({ description: 'Timestamp when status was last updated' }) + @ApiProperty({ description: "Timestamp when status was last updated" }) lastUpdated: string; - @ApiProperty({ description: 'Timestamp when scan was started' }) + @ApiProperty({ description: "Timestamp when scan was started" }) startedAt: string; - @ApiPropertyOptional({ description: 'Timestamp when scan was completed' }) + @ApiPropertyOptional({ description: "Timestamp when scan was completed" }) completedAt?: string; - @ApiPropertyOptional({ description: 'Error message if scan failed' }) + @ApiPropertyOptional({ description: "Error message if scan failed" }) errorMessage?: string; } export class QueueStatusDto { - @ApiProperty({ description: 'Number of jobs in queue' }) + @ApiProperty({ description: "Number of jobs in queue" }) queueLength: number; - @ApiProperty({ description: 'Number of active scans' }) + @ApiProperty({ description: "Number of active scans" }) activeScans: number; - @ApiProperty({ description: 'Number of completed scans' }) + @ApiProperty({ description: "Number of completed scans" }) completedScans: number; - @ApiProperty({ description: 'Average processing time in seconds' }) + @ApiProperty({ description: "Average processing time in seconds" }) averageProcessingTime: number; - @ApiProperty({ description: 'Estimated wait time for new scans in seconds' }) + @ApiProperty({ description: "Estimated wait time for new scans in seconds" }) estimatedWaitTime: number; } diff --git a/apps/api/src/modules/scan/scan.controller.ts b/apps/api/src/modules/scan/scan.controller.ts index 0a425a0..bd4a653 100644 --- a/apps/api/src/modules/scan/scan.controller.ts +++ b/apps/api/src/modules/scan/scan.controller.ts @@ -1,105 +1,123 @@ -import { Controller, Post, Body, Get, Query, HttpCode, HttpStatus, Version, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; -import { ScanService } from './scan.service'; -import { ScanRequestDto, ScanResponseDto, ScanStatusDto } from './dto/scan.dto'; -import { Public } from '../../auth/decorators'; +import { + Controller, + Post, + Body, + Get, + Query, + HttpCode, + HttpStatus, + Version, + Param, +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiBody } from "@nestjs/swagger"; +import { ScanService } from "./scan.service"; +import { ScanRequestDto, ScanResponseDto, ScanStatusDto } from "./dto/scan.dto"; +import { Public } from "../../auth/decorators"; -@ApiTags('scan') -@Controller('scan') -@Version('1') +@ApiTags("scan") +@Controller("scan") +@Version("1") @Public() export class ScanController { constructor(private readonly scanService: ScanService) {} @Post() @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Perform real-time code scan' }) + @ApiOperation({ summary: "Perform real-time code scan" }) @ApiBody({ type: ScanRequestDto }) - @ApiResponse({ - status: 200, - description: 'Scan completed successfully', - type: ScanResponseDto + @ApiResponse({ + status: 200, + description: "Scan completed successfully", + type: ScanResponseDto, }) - @ApiResponse({ - status: 400, - description: 'Invalid scan request' + @ApiResponse({ + status: 400, + description: "Invalid scan request", }) - async scanCode(@Body() scanRequest: ScanRequestDto): Promise { + async scanCode( + @Body() scanRequest: ScanRequestDto, + ): Promise { return this.scanService.performScan(scanRequest); } - @Post('async') - @ApiOperation({ summary: 'Start asynchronous scan' }) + @Post("async") + @ApiOperation({ summary: "Start asynchronous scan" }) @ApiBody({ type: ScanRequestDto }) - @ApiResponse({ - status: 202, - description: 'Scan started successfully', - type: ScanStatusDto + @ApiResponse({ + status: 202, + description: "Scan started successfully", + type: ScanStatusDto, }) - async startAsyncScan(@Body() scanRequest: ScanRequestDto): Promise { + async startAsyncScan( + @Body() scanRequest: ScanRequestDto, + ): Promise { return this.scanService.startAsyncScan(scanRequest); } - @Get('status/:scanId') - @ApiOperation({ summary: 'Get scan status' }) - @ApiResponse({ - status: 200, - description: 'Scan status retrieved', - type: ScanStatusDto + @Get("status/:scanId") + @ApiOperation({ summary: "Get scan status" }) + @ApiResponse({ + status: 200, + description: "Scan status retrieved", + type: ScanStatusDto, }) - async getScanStatus(@Param('scanId') scanId: string): Promise { + async getScanStatus(@Param("scanId") scanId: string): Promise { return this.scanService.getScanStatus(scanId); } - @Get('results/:scanId') - @ApiOperation({ summary: 'Get scan results' }) - @ApiResponse({ - status: 200, - description: 'Scan results retrieved', - type: ScanResponseDto + @Get("results/:scanId") + @ApiOperation({ summary: "Get scan results" }) + @ApiResponse({ + status: 200, + description: "Scan results retrieved", + type: ScanResponseDto, }) - async getScanResults(@Param('scanId') scanId: string): Promise { + async getScanResults( + @Param("scanId") scanId: string, + ): Promise { return this.scanService.getScanResults(scanId); } - @Post('cancel/:scanId') - @ApiOperation({ summary: 'Cancel running scan' }) - @ApiResponse({ - status: 200, - description: 'Scan cancelled successfully' + @Post("cancel/:scanId") + @ApiOperation({ summary: "Cancel running scan" }) + @ApiResponse({ + status: 200, + description: "Scan cancelled successfully", }) - async cancelScan(@Param('scanId') scanId: string) { + async cancelScan(@Param("scanId") scanId: string) { return this.scanService.cancelScan(scanId); } - @Get('results/:scanId') - @ApiOperation({ summary: 'Get scan results' }) - @ApiResponse({ - status: 200, - description: 'Scan results retrieved', - type: ScanResponseDto + @Get("results/:scanId") + @ApiOperation({ summary: "Get scan results" }) + @ApiResponse({ + status: 200, + description: "Scan results retrieved", + type: ScanResponseDto, }) - async getScanResults(@Query('scanId') scanId: string): Promise { + async getScanResults( + @Query("scanId") scanId: string, + ): Promise { return this.scanService.getScanResults(scanId); } - @Get('queue') - @ApiOperation({ summary: 'Get scan queue status' }) - @ApiResponse({ - status: 200, - description: 'Queue status retrieved' + @Get("queue") + @ApiOperation({ summary: "Get scan queue status" }) + @ApiResponse({ + status: 200, + description: "Queue status retrieved", }) async getQueueStatus() { return this.scanService.getQueueStatus(); } - @Post('cancel/:scanId') - @ApiOperation({ summary: 'Cancel running scan' }) - @ApiResponse({ - status: 200, - description: 'Scan cancelled successfully' + @Post("cancel/:scanId") + @ApiOperation({ summary: "Cancel running scan" }) + @ApiResponse({ + status: 200, + description: "Scan cancelled successfully", }) - async cancelScan(@Query('scanId') scanId: string) { + async cancelScan(@Query("scanId") scanId: string) { return this.scanService.cancelScan(scanId); } } diff --git a/apps/api/src/modules/scan/scan.module.ts b/apps/api/src/modules/scan/scan.module.ts index ca43e6c..308e563 100644 --- a/apps/api/src/modules/scan/scan.module.ts +++ b/apps/api/src/modules/scan/scan.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { ScanController } from './scan.controller'; -import { ScanService } from './scan.service'; +import { Module } from "@nestjs/common"; +import { ScanController } from "./scan.controller"; +import { ScanService } from "./scan.service"; @Module({ controllers: [ScanController], diff --git a/apps/api/src/modules/scan/scan.service.ts b/apps/api/src/modules/scan/scan.service.ts index 51dc1fc..5f32079 100644 --- a/apps/api/src/modules/scan/scan.service.ts +++ b/apps/api/src/modules/scan/scan.service.ts @@ -1,28 +1,32 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { - ScanRequestDto, - ScanResponseDto, - ScanStatusDto, - ScanStatus, +import { + Injectable, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { + ScanRequestDto, + ScanResponseDto, + ScanStatusDto, + ScanStatus, QueueStatusDto, FindingDto, - SummaryDto -} from './dto/scan.dto'; + SummaryDto, +} from "./dto/scan.dto"; // Severity enum enum Severity { - CRITICAL = 'critical', - HIGH = 'high', - MEDIUM = 'medium', - LOW = 'low', - INFO = 'info' + CRITICAL = "critical", + HIGH = "high", + MEDIUM = "medium", + LOW = "low", + INFO = "info", } // Simplified interfaces for now interface Finding { ruleId: string; message: string; - severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + severity: "critical" | "high" | "medium" | "low" | "info"; location: { file: string; startLine: number; @@ -67,7 +71,10 @@ export class ScanService { constructor() {} private generateId(): string { - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); } async performScan(scanRequest: ScanRequestDto): Promise { @@ -80,9 +87,9 @@ export class ScanService { scanId, status: ScanStatus.RUNNING, progress: 0, - currentOperation: 'Initializing scan', + currentOperation: "Initializing scan", lastUpdated: new Date().toISOString(), - startedAt: new Date().toISOString() + startedAt: new Date().toISOString(), }; this.activeScans.set(scanId, status); @@ -104,17 +111,28 @@ export class ScanService { errors: result.errors?.map((err: any) => ({ file: err.file, message: err.message, - error: err.error?.message + error: err.error?.message, })), - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; this.scanResults.set(scanId, response); - this.updateScanStatus(scanId, ScanStatus.COMPLETED, 100, 'Scan completed'); + this.updateScanStatus( + scanId, + ScanStatus.COMPLETED, + 100, + "Scan completed", + ); return response; } catch (error) { - this.updateScanStatus(scanId, ScanStatus.FAILED, 0, 'Scan failed', (error as Error).message); + this.updateScanStatus( + scanId, + ScanStatus.FAILED, + 0, + "Scan failed", + (error as Error).message, + ); throw error; } } @@ -126,9 +144,9 @@ export class ScanService { scanId, status: ScanStatus.PENDING, progress: 0, - currentOperation: 'Queued for processing', + currentOperation: "Queued for processing", lastUpdated: new Date().toISOString(), - startedAt: new Date().toISOString() + startedAt: new Date().toISOString(), }; this.activeScans.set(scanId, status); @@ -139,7 +157,13 @@ export class ScanService { try { await this.performScan(scanRequest); } catch (error) { - this.updateScanStatus(scanId, ScanStatus.FAILED, 0, 'Scan failed', (error as Error).message); + this.updateScanStatus( + scanId, + ScanStatus.FAILED, + 0, + "Scan failed", + (error as Error).message, + ); } }, 100); @@ -170,12 +194,14 @@ export class ScanService { } async getQueueStatus(): Promise { - const activeScans = Array.from(this.activeScans.values()) - .filter(status => status.status === ScanStatus.RUNNING).length; - + const activeScans = Array.from(this.activeScans.values()).filter( + (status) => status.status === ScanStatus.RUNNING, + ).length; + const completedScans = this.scanResults.size; - const queueLength = Array.from(this.activeScans.values()) - .filter(status => status.status === ScanStatus.PENDING).length; + const queueLength = Array.from(this.activeScans.values()).filter( + (status) => status.status === ScanStatus.PENDING, + ).length; // Mock values for now const averageProcessingTime = 30; @@ -186,7 +212,7 @@ export class ScanService { activeScans, completedScans, averageProcessingTime, - estimatedWaitTime + estimatedWaitTime, }; } @@ -200,41 +226,49 @@ export class ScanService { throw new BadRequestException(`Scan ${scanId} has already completed`); } - this.updateScanStatus(scanId, ScanStatus.CANCELLED, 0, 'Scan cancelled by user'); + this.updateScanStatus( + scanId, + ScanStatus.CANCELLED, + 0, + "Scan cancelled by user", + ); return { message: `Scan ${scanId} cancelled successfully` }; } - private async executeScan(scanRequest: ScanRequestDto, scanId: string): Promise { - this.updateScanStatus(scanId, ScanStatus.RUNNING, 10, 'Parsing code'); + private async executeScan( + scanRequest: ScanRequestDto, + scanId: string, + ): Promise { + this.updateScanStatus(scanId, ScanStatus.RUNNING, 10, "Parsing code"); // Mock analyzer implementation - in real scenario, this would use the actual analyzer const mockFindings: Finding[] = await this.analyzeCode(scanRequest); - this.updateScanStatus(scanId, ScanStatus.RUNNING, 80, 'Analyzing results'); + this.updateScanStatus(scanId, ScanStatus.RUNNING, 80, "Analyzing results"); // Apply basic scoring - const scoredFindings = mockFindings.map(finding => { + const scoredFindings = mockFindings.map((finding) => { const score = this.calculateBasicScore(finding); return { ...finding, metadata: { ...finding.metadata, score, - riskLevel: this.getRiskLevel(score) - } + riskLevel: this.getRiskLevel(score), + }, }; }); - this.updateScanStatus(scanId, ScanStatus.RUNNING, 95, 'Finalizing results'); + this.updateScanStatus(scanId, ScanStatus.RUNNING, 95, "Finalizing results"); return { findings: scoredFindings, filesAnalyzed: 1, analysisTime: 0, // Will be set by caller - analyzerVersion: '1.0.0', + analyzerVersion: "1.0.0", summary: this.calculateSummary(scoredFindings), - totalEstimatedGasSavings: this.calculateGasSavings(scoredFindings) + totalEstimatedGasSavings: this.calculateGasSavings(scoredFindings), }; } @@ -250,37 +284,37 @@ export class ScanService { private getMockFindings(scanRequest: ScanRequestDto): Finding[] { const findings: Finding[] = []; - if (scanRequest.code.includes('require(msg.sender == owner)')) { + if (scanRequest.code.includes("require(msg.sender == owner)")) { findings.push({ - ruleId: 'inefficient-access-control', - message: 'Inefficient access control pattern detected', + ruleId: "inefficient-access-control", + message: "Inefficient access control pattern detected", severity: Severity.MEDIUM, location: { - file: scanRequest.filePath || 'contract.sol', + file: scanRequest.filePath || "contract.sol", startLine: 1, - endLine: 1 + endLine: 1, }, estimatedGasSavings: 2000, suggestedFix: { - description: 'Use modifiers or role-based access control' - } + description: "Use modifiers or role-based access control", + }, }); } - if (scanRequest.code.includes('.call(')) { + if (scanRequest.code.includes(".call(")) { findings.push({ - ruleId: 'unchecked-call', - message: 'Unchecked external call detected', + ruleId: "unchecked-call", + message: "Unchecked external call detected", severity: Severity.HIGH, location: { - file: scanRequest.filePath || 'contract.sol', + file: scanRequest.filePath || "contract.sol", startLine: 1, - endLine: 1 + endLine: 1, }, estimatedGasSavings: 21000, suggestedFix: { - description: 'Check return value of external calls' - } + description: "Check return value of external calls", + }, }); } @@ -288,11 +322,11 @@ export class ScanService { } private updateScanStatus( - scanId: string, - status: ScanStatus, - progress: number, - operation: string, - errorMessage?: string + scanId: string, + status: ScanStatus, + progress: number, + operation: string, + errorMessage?: string, ): void { const currentStatus = this.activeScans.get(scanId); if (!currentStatus) return; @@ -304,9 +338,12 @@ export class ScanService { currentOperation: operation, lastUpdated: new Date().toISOString(), errorMessage, - completedAt: (status === ScanStatus.COMPLETED || status === ScanStatus.FAILED || status === ScanStatus.CANCELLED) - ? new Date().toISOString() - : undefined + completedAt: + status === ScanStatus.COMPLETED || + status === ScanStatus.FAILED || + status === ScanStatus.CANCELLED + ? new Date().toISOString() + : undefined, }; this.activeScans.set(scanId, updatedStatus); @@ -321,14 +358,17 @@ export class ScanService { estimatedGasSavings: finding.estimatedGasSavings, suggestedFix: finding.suggestedFix, score: finding.metadata?.score, - riskLevel: finding.metadata?.riskLevel + riskLevel: finding.metadata?.riskLevel, }; } private convertSummary(summary: any, findings: Finding[]): SummaryDto { - const totalScore = findings.reduce((sum, f) => sum + (f.metadata?.score || 0), 0); + const totalScore = findings.reduce( + (sum, f) => sum + (f.metadata?.score || 0), + 0, + ); const riskLevel = this.getRiskLevel(totalScore / findings.length); - + return { critical: summary.critical || 0, high: summary.high || 0, @@ -336,57 +376,57 @@ export class ScanService { low: summary.low || 0, info: summary.info || 0, totalScore, - riskLevel + riskLevel, }; } private calculateSummary(findings: Finding[]): any { return { - critical: findings.filter(f => f.severity === Severity.CRITICAL).length, - high: findings.filter(f => f.severity === Severity.HIGH).length, - medium: findings.filter(f => f.severity === Severity.MEDIUM).length, - low: findings.filter(f => f.severity === Severity.LOW).length, - info: findings.filter(f => f.severity === Severity.INFO).length + critical: findings.filter((f) => f.severity === Severity.CRITICAL).length, + high: findings.filter((f) => f.severity === Severity.HIGH).length, + medium: findings.filter((f) => f.severity === Severity.MEDIUM).length, + low: findings.filter((f) => f.severity === Severity.LOW).length, + info: findings.filter((f) => f.severity === Severity.INFO).length, }; } private calculateGasSavings(findings: Finding[]): number { return findings - .filter(f => f.estimatedGasSavings) + .filter((f) => f.estimatedGasSavings) .reduce((sum, f) => sum + (f.estimatedGasSavings || 0), 0); } private calculateBasicScore(finding: Finding): number { let score = 0; switch (finding.severity) { - case 'critical': + case "critical": score = 90; break; - case 'high': + case "high": score = 70; break; - case 'medium': + case "medium": score = 50; break; - case 'low': + case "low": score = 30; break; - case 'info': + case "info": score = 10; break; } - + if (finding.estimatedGasSavings) { score += Math.min(finding.estimatedGasSavings / 1000, 20); // Max 20 points for gas savings } - + return score; } - private getRiskLevel(score: number): 'critical' | 'high' | 'medium' | 'low' { - if (score >= 80) return 'critical'; - if (score >= 60) return 'high'; - if (score >= 40) return 'medium'; - return 'low'; + private getRiskLevel(score: number): "critical" | "high" | "medium" | "low" { + if (score >= 80) return "critical"; + if (score >= 60) return "high"; + if (score >= 40) return "medium"; + return "low"; } } diff --git a/apps/api/src/modules/simulation/simulation.routes.ts b/apps/api/src/modules/simulation/simulation.routes.ts index c122b7a..1b2d58d 100644 --- a/apps/api/src/modules/simulation/simulation.routes.ts +++ b/apps/api/src/modules/simulation/simulation.routes.ts @@ -1,7 +1,7 @@ -import { Request, Response, Router } from 'express'; -import { SimulationEngine } from '@simulation/index'; -import { EvmAdapter, SorobanAdapter } from '@chains/index'; -import { RpcClient } from '@rpc/index'; +import { Request, Response, Router } from "express"; +import { SimulationEngine } from "@simulation/index"; +import { EvmAdapter, SorobanAdapter } from "@chains/index"; +import { RpcClient } from "@rpc/index"; export function createSimulationRoutes(): Router { const router = Router(); @@ -9,14 +9,20 @@ export function createSimulationRoutes(): Router { // Helper to get engine based on chain const getEngine = (chain: string, endpoints: any[]) => { const rpcClient = new RpcClient(endpoints); - const adapter = chain === 'soroban' ? new SorobanAdapter(rpcClient) : new EvmAdapter(rpcClient); + const adapter = + chain === "soroban" + ? new SorobanAdapter(rpcClient) + : new EvmAdapter(rpcClient); return new SimulationEngine(adapter); }; - router.post('/simulate', async (req: Request, res: Response) => { + router.post("/simulate", async (req: Request, res: Response) => { try { const { code, chain, method, params, endpoints } = req.body; - const engine = getEngine(chain, endpoints || [{ url: 'http://localhost:8545', weight: 1, priority: 1 }]); + const engine = getEngine( + chain, + endpoints || [{ url: "http://localhost:8545", weight: 1, priority: 1 }], + ); const result = await engine.simulateExecution(code, method, params || []); res.json(result); } catch (error: any) { @@ -24,11 +30,20 @@ export function createSimulationRoutes(): Router { } }); - router.post('/compare', async (req: Request, res: Response) => { + router.post("/compare", async (req: Request, res: Response) => { try { - const { originalCode, optimizedCode, chain, method, params, endpoints } = req.body; - const engine = getEngine(chain, endpoints || [{ url: 'http://localhost:8545', weight: 1, priority: 1 }]); - const report = await engine.compareOptimizations(originalCode, optimizedCode, method, params || []); + const { originalCode, optimizedCode, chain, method, params, endpoints } = + req.body; + const engine = getEngine( + chain, + endpoints || [{ url: "http://localhost:8545", weight: 1, priority: 1 }], + ); + const report = await engine.compareOptimizations( + originalCode, + optimizedCode, + method, + params || [], + ); res.json(report); } catch (error: any) { res.status(500).json({ error: error.message }); diff --git a/apps/api/src/queue/index.ts b/apps/api/src/queue/index.ts index 43316f5..78e4122 100644 --- a/apps/api/src/queue/index.ts +++ b/apps/api/src/queue/index.ts @@ -1,37 +1,43 @@ -import { Queue, Worker, QueueEvents, JobsOptions } from 'bullmq' -import { createClient } from 'ioredis' -import { performScan } from '../scan.js' -import { createInMemoryQueue } from './memory.js' -import { cacheService } from '../common/cache/index.js' +import { Queue, Worker, QueueEvents, JobsOptions } from "bullmq"; +import { createClient } from "ioredis"; +import { performScan } from "../scan.js"; +import { createInMemoryQueue } from "./memory.js"; +import { cacheService } from "../common/cache/index.js"; -type InitOptions = { redisUrl: string; queueName: string } +type InitOptions = { redisUrl: string; queueName: string }; export function initQueue({ redisUrl, queueName }: InitOptions) { if (!redisUrl) { - const { queue, worker, events } = createInMemoryQueue(queueName) - return { queue, worker, events } + const { queue, worker, events } = createInMemoryQueue(queueName); + return { queue, worker, events }; } - const connection = new createClient(redisUrl) - const queue = new Queue(queueName, { connection }) - const events = new QueueEvents(queueName, { connection }) + const connection = new createClient(redisUrl); + const queue = new Queue(queueName, { connection }); + const events = new QueueEvents(queueName, { connection }); const worker = new Worker( queueName, - async job => { - const payload = job.data.payload - await job.updateProgress(10) - const result = await performScan(payload, p => job.updateProgress(p)) - + async (job) => { + const payload = job.data.payload; + await job.updateProgress(10); + const result = await performScan(payload, (p) => job.updateProgress(p)); + // Cache the result if it has project info if (payload?.project?.repositoryUrl && payload?.project?.commitHash) { - const cacheKey = cacheService.generateKey(payload.project.repositoryUrl, payload.project.commitHash); + const cacheKey = cacheService.generateKey( + payload.project.repositoryUrl, + payload.project.commitHash, + ); await cacheService.set(cacheKey, { ...result, jobId: job.id }); } - return result + return result; }, - { connection } - ) - return { queue, worker, events } + { connection }, + ); + return { queue, worker, events }; } -export const defaultJobOptions: JobsOptions = { removeOnComplete: true, removeOnFail: true } \ No newline at end of file +export const defaultJobOptions: JobsOptions = { + removeOnComplete: true, + removeOnFail: true, +}; diff --git a/apps/api/src/rate-limiting/__tests__/rate-limit.integration.spec.ts b/apps/api/src/rate-limiting/__tests__/rate-limit.integration.spec.ts index 71414c6..51230b8 100644 --- a/apps/api/src/rate-limiting/__tests__/rate-limit.integration.spec.ts +++ b/apps/api/src/rate-limiting/__tests__/rate-limit.integration.spec.ts @@ -1,17 +1,17 @@ /** * Rate Limit Integration Tests - * + * * Integration tests for the rate limiting system including admin endpoints * and header responses. Uses mocked Redis for isolation. */ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, Controller, Get, Version } from '@nestjs/common'; -import { RateLimitingModule } from '../rate-limiting.module'; -import { RedisService } from '../services/redis.service'; -import { RateLimitService } from '../services/rate-limit.service'; -import { RateLimitGuard } from '../guards/rate-limit.guard'; -import { TierPlan, RATE_LIMIT_HEADERS } from '../schemas/rate-limit.schema'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, Controller, Get, Version } from "@nestjs/common"; +import { RateLimitingModule } from "../rate-limiting.module"; +import { RedisService } from "../services/redis.service"; +import { RateLimitService } from "../services/rate-limit.service"; +import { RateLimitGuard } from "../guards/rate-limit.guard"; +import { TierPlan, RATE_LIMIT_HEADERS } from "../schemas/rate-limit.schema"; // Mock Redis for integration tests const createMockRedisClient = () => ({ @@ -24,25 +24,30 @@ const createMockRedisClient = () => ({ hgetall: jest.fn(), pipeline: jest.fn(), quit: jest.fn(), - status: 'ready', + status: "ready", on: jest.fn(), connect: jest.fn(), }); let mockRedisClient: ReturnType; -let mockPipeline: { incr: jest.Mock; expire: jest.Mock; hset: jest.Mock; exec: jest.Mock }; +let mockPipeline: { + incr: jest.Mock; + expire: jest.Mock; + hset: jest.Mock; + exec: jest.Mock; +}; // Test controller for integration tests -@Controller('test') +@Controller("test") class TestController { - @Version('1') - @Get('rate-limited') + @Version("1") + @Get("rate-limited") getRateLimited() { - return { message: 'Success' }; + return { message: "Success" }; } } -describe('Rate Limiting Integration', () => { +describe("Rate Limiting Integration", () => { let app: INestApplication; let redisService: RedisService; let rateLimitService: RateLimitService; @@ -63,10 +68,10 @@ describe('Rate Limiting Integration', () => { RateLimitingModule.forRoot({ config: { enabled: true, - fallbackMode: 'permissive', + fallbackMode: "permissive", defaultTier: TierPlan.FREE, redis: { - host: 'localhost', + host: "localhost", port: 6379, enableReadyCheck: true, maxRetriesPerRequest: 3, @@ -81,7 +86,11 @@ describe('Rate Limiting Integration', () => { isReady: jest.fn().mockReturnValue(true), getClient: jest.fn().mockReturnValue(mockRedisClient), execute: jest.fn((fn) => fn(mockRedisClient)), - healthCheck: jest.fn().mockResolvedValue({ status: 'healthy', connected: true, latency: 5 }), + healthCheck: jest.fn().mockResolvedValue({ + status: "healthy", + connected: true, + latency: 5, + }), }) .compile(); @@ -99,61 +108,61 @@ describe('Rate Limiting Integration', () => { } }); - describe('Admin Endpoints', () => { - describe('getUsage', () => { - it('should return usage statistics for an API key', async () => { + describe("Admin Endpoints", () => { + describe("getUsage", () => { + it("should return usage statistics for an API key", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-key-123', + apiKey: "test-key-123", tier: TierPlan.STANDARD, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); mockRedisClient.get.mockImplementation((key: string) => { - if (key.includes('minute')) return Promise.resolve('5'); - if (key.includes('hour')) return Promise.resolve('50'); - if (key.includes('day')) return Promise.resolve('200'); - return Promise.resolve('0'); + if (key.includes("minute")) return Promise.resolve("5"); + if (key.includes("hour")) return Promise.resolve("50"); + if (key.includes("day")) return Promise.resolve("200"); + return Promise.resolve("0"); }); - const usage = await rateLimitService.getUsage('test-key-123'); + const usage = await rateLimitService.getUsage("test-key-123"); expect(usage).not.toBeNull(); - expect(usage!.apiKey).toBe('test-key-123'); + expect(usage!.apiKey).toBe("test-key-123"); expect(usage!.tier).toBe(TierPlan.STANDARD); expect(usage!.minute.used).toBe(5); expect(usage!.hour.used).toBe(50); expect(usage!.day.used).toBe(200); }); - it('should return default usage for new API keys', async () => { + it("should return default usage for new API keys", async () => { mockRedisClient.hgetall.mockResolvedValue({}); // No existing config - const usage = await rateLimitService.getUsage('new-key-456'); + const usage = await rateLimitService.getUsage("new-key-456"); // Returns null when no config found - controller handles default expect(usage).toBeNull(); }); - it('should return null when Redis is unavailable', async () => { - jest.spyOn(redisService, 'isReady').mockReturnValue(false); + it("should return null when Redis is unavailable", async () => { + jest.spyOn(redisService, "isReady").mockReturnValue(false); + + const usage = await rateLimitService.getUsage("test-key"); - const usage = await rateLimitService.getUsage('test-key'); - expect(usage).toBeNull(); }); }); - describe('updateQuota', () => { - it('should update quota for an API key', async () => { + describe("updateQuota", () => { + it("should update quota for an API key", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-key-123', + apiKey: "test-key-123", tier: TierPlan.FREE, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); - await rateLimitService.updateQuota('test-key-123', { + await rateLimitService.updateQuota("test-key-123", { requestsPerMinute: 20, requestsPerHour: 200, requestsPerDay: 1000, @@ -161,83 +170,85 @@ describe('Rate Limiting Integration', () => { expect(mockRedisClient.hset).toHaveBeenCalled(); const hsetCall = mockRedisClient.hset.mock.calls[0]; - expect(hsetCall[1].apiKey).toBe('test-key-123'); + expect(hsetCall[1].apiKey).toBe("test-key-123"); }); - it('should update tier for an API key', async () => { + it("should update tier for an API key", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-key-123', + apiKey: "test-key-123", tier: TierPlan.FREE, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); - await rateLimitService.setTier('test-key-123', TierPlan.PREMIUM); + await rateLimitService.setTier("test-key-123", TierPlan.PREMIUM); expect(mockRedisClient.hset).toHaveBeenCalled(); const hsetCall = mockRedisClient.hset.mock.calls[0]; expect(hsetCall[1].tier).toBe(TierPlan.PREMIUM); }); - it('should throw error when Redis is unavailable', async () => { - jest.spyOn(redisService, 'isReady').mockReturnValue(false); + it("should throw error when Redis is unavailable", async () => { + jest.spyOn(redisService, "isReady").mockReturnValue(false); await expect( - rateLimitService.updateQuota('test-key', { requestsPerMinute: 20 }), - ).rejects.toThrow('Redis unavailable'); + rateLimitService.updateQuota("test-key", { requestsPerMinute: 20 }), + ).rejects.toThrow("Redis unavailable"); }); }); - describe('resetCounter', () => { - it('should reset counters for an API key', async () => { + describe("resetCounter", () => { + it("should reset counters for an API key", async () => { mockRedisClient.del.mockResolvedValue(3); - await rateLimitService.resetCounter('test-key-123'); + await rateLimitService.resetCounter("test-key-123"); expect(mockRedisClient.del).toHaveBeenCalled(); }); - it('should throw error when Redis is unavailable', async () => { - jest.spyOn(redisService, 'isReady').mockReturnValue(false); + it("should throw error when Redis is unavailable", async () => { + jest.spyOn(redisService, "isReady").mockReturnValue(false); - await expect(rateLimitService.resetCounter('test-key')).rejects.toThrow('Redis unavailable'); + await expect(rateLimitService.resetCounter("test-key")).rejects.toThrow( + "Redis unavailable", + ); }); }); }); - describe('Rate Limit Headers', () => { - it('should include rate limit headers in checkLimit response', async () => { - mockRedisClient.get.mockResolvedValue('0'); // No requests yet + describe("Rate Limit Headers", () => { + it("should include rate limit headers in checkLimit response", async () => { + mockRedisClient.get.mockResolvedValue("0"); // No requests yet + + const result = await rateLimitService.checkLimit("test-key"); - const result = await rateLimitService.checkLimit('test-key'); - expect(result.limit).toBeGreaterThan(0); expect(result.remaining).toBeGreaterThanOrEqual(0); expect(result.resetTime).toBeGreaterThan(0); }); }); - describe('Rate Limit Enforcement', () => { - it('should track requests correctly', async () => { - mockRedisClient.get.mockResolvedValue('0'); + describe("Rate Limit Enforcement", () => { + it("should track requests correctly", async () => { + mockRedisClient.get.mockResolvedValue("0"); + + const result = await rateLimitService.checkLimit("test-key"); - const result = await rateLimitService.checkLimit('test-key'); - expect(result.allowed).toBe(true); expect(result.remaining).toBe(9); // 10 - 0 - 1 }); - it('should block requests when limit exceeded', async () => { - mockRedisClient.get.mockResolvedValue('10'); // At limit + it("should block requests when limit exceeded", async () => { + mockRedisClient.get.mockResolvedValue("10"); // At limit + + const result = await rateLimitService.checkLimit("test-key"); - const result = await rateLimitService.checkLimit('test-key'); - expect(result.allowed).toBe(false); expect(result.remaining).toBe(0); }); - it('should increment counters on successful requests', async () => { - await rateLimitService.incrementCounter('test-key'); + it("should increment counters on successful requests", async () => { + await rateLimitService.incrementCounter("test-key"); expect(mockPipeline.incr).toHaveBeenCalledTimes(3); expect(mockPipeline.expire).toHaveBeenCalledTimes(3); @@ -245,55 +256,55 @@ describe('Rate Limiting Integration', () => { }); }); - describe('Tier-based Quotas', () => { - it('should apply FREE tier quotas by default', async () => { + describe("Tier-based Quotas", () => { + it("should apply FREE tier quotas by default", async () => { mockRedisClient.hgetall.mockResolvedValue({}); // No custom config - mockRedisClient.get.mockResolvedValue('0'); + mockRedisClient.get.mockResolvedValue("0"); + + const result = await rateLimitService.checkLimit("new-key"); - const result = await rateLimitService.checkLimit('new-key'); - expect(result.limit).toBe(10); // FREE tier requestsPerMinute }); - it('should apply STANDARD tier quotas', async () => { + it("should apply STANDARD tier quotas", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'standard-key', + apiKey: "standard-key", tier: TierPlan.STANDARD, }); - mockRedisClient.get.mockResolvedValue('0'); + mockRedisClient.get.mockResolvedValue("0"); + + const result = await rateLimitService.checkLimit("standard-key"); - const result = await rateLimitService.checkLimit('standard-key'); - expect(result.limit).toBe(60); // STANDARD tier requestsPerMinute }); - it('should apply PREMIUM tier quotas', async () => { + it("should apply PREMIUM tier quotas", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'premium-key', + apiKey: "premium-key", tier: TierPlan.PREMIUM, }); - mockRedisClient.get.mockResolvedValue('0'); + mockRedisClient.get.mockResolvedValue("0"); + + const result = await rateLimitService.checkLimit("premium-key"); - const result = await rateLimitService.checkLimit('premium-key'); - expect(result.limit).toBe(300); // PREMIUM tier requestsPerMinute }); }); - describe('Redis Outage Fallback', () => { - it('should allow requests in permissive mode when Redis is down', async () => { - jest.spyOn(redisService, 'isReady').mockReturnValue(false); + describe("Redis Outage Fallback", () => { + it("should allow requests in permissive mode when Redis is down", async () => { + jest.spyOn(redisService, "isReady").mockReturnValue(false); + + const result = await rateLimitService.checkLimit("test-key"); - const result = await rateLimitService.checkLimit('test-key'); - expect(result.allowed).toBe(true); }); - it('should return null usage when Redis is down', async () => { - jest.spyOn(redisService, 'isReady').mockReturnValue(false); + it("should return null usage when Redis is down", async () => { + jest.spyOn(redisService, "isReady").mockReturnValue(false); + + const usage = await rateLimitService.getUsage("test-key"); - const usage = await rateLimitService.getUsage('test-key'); - expect(usage).toBeNull(); }); }); diff --git a/apps/api/src/rate-limiting/__tests__/rate-limit.service.spec.ts b/apps/api/src/rate-limiting/__tests__/rate-limit.service.spec.ts index da6278c..e191a6a 100644 --- a/apps/api/src/rate-limiting/__tests__/rate-limit.service.spec.ts +++ b/apps/api/src/rate-limiting/__tests__/rate-limit.service.spec.ts @@ -1,20 +1,20 @@ /** * Rate Limit Service Unit Tests - * + * * Tests for the core rate limiting logic including sliding window algorithm, * quota enforcement, and Redis outage fallback. */ -import { Test, TestingModule } from '@nestjs/testing'; -import { RateLimitService } from '../services/rate-limit.service'; -import { RedisService } from '../services/redis.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { RateLimitService } from "../services/rate-limit.service"; +import { RedisService } from "../services/redis.service"; import { TierPlan, QuotaConfig, DEFAULT_TIER_QUOTAS, WINDOW_DURATIONS, -} from '../schemas/rate-limit.schema'; -import { RateLimitConfig } from '../config/rate-limit.config'; +} from "../schemas/rate-limit.schema"; +import { RateLimitConfig } from "../config/rate-limit.config"; // Mock Redis const mockRedisClient = { @@ -44,17 +44,17 @@ const mockRedisService = { execute: jest.fn(), }; -describe('RateLimitService', () => { +describe("RateLimitService", () => { let service: RateLimitService; const mockConfig: RateLimitConfig = { redis: { - host: 'localhost', + host: "localhost", port: 6379, enableReadyCheck: true, maxRetriesPerRequest: 3, }, - fallbackMode: 'permissive', + fallbackMode: "permissive", defaultTier: TierPlan.FREE, enabled: true, }; @@ -68,7 +68,7 @@ describe('RateLimitService', () => { providers: [ RateLimitService, { - provide: 'RATE_LIMIT_CONFIG', + provide: "RATE_LIMIT_CONFIG", useValue: mockConfig, }, { @@ -81,65 +81,69 @@ describe('RateLimitService', () => { service = module.get(RateLimitService); }); - describe('checkLimit', () => { - it('should allow request when under limit', async () => { - mockRedisClient.get.mockResolvedValue('5'); // 5 requests made + describe("checkLimit", () => { + it("should allow request when under limit", async () => { + mockRedisClient.get.mockResolvedValue("5"); // 5 requests made - const result = await service.checkLimit('test-api-key'); + const result = await service.checkLimit("test-api-key"); expect(result.allowed).toBe(true); - expect(result.limit).toBe(DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute); - expect(result.remaining).toBe(DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute - 5 - 1); + expect(result.limit).toBe( + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute, + ); + expect(result.remaining).toBe( + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute - 5 - 1, + ); }); - it('should deny request when minute limit exceeded', async () => { + it("should deny request when minute limit exceeded", async () => { mockRedisClient.get.mockImplementation((key: string) => { - if (key.includes('minute')) return Promise.resolve('10'); // At limit - return Promise.resolve('0'); + if (key.includes("minute")) return Promise.resolve("10"); // At limit + return Promise.resolve("0"); }); - const result = await service.checkLimit('test-api-key'); + const result = await service.checkLimit("test-api-key"); expect(result.allowed).toBe(false); expect(result.remaining).toBe(0); - expect(result.window).toBe('minute'); + expect(result.window).toBe("minute"); }); - it('should deny request when hour limit exceeded', async () => { + it("should deny request when hour limit exceeded", async () => { mockRedisClient.get.mockImplementation((key: string) => { - if (key.includes('minute')) return Promise.resolve('5'); - if (key.includes('hour')) return Promise.resolve('100'); // At limit - return Promise.resolve('0'); + if (key.includes("minute")) return Promise.resolve("5"); + if (key.includes("hour")) return Promise.resolve("100"); // At limit + return Promise.resolve("0"); }); - const result = await service.checkLimit('test-api-key'); + const result = await service.checkLimit("test-api-key"); expect(result.allowed).toBe(false); - expect(result.window).toBe('hour'); + expect(result.window).toBe("hour"); }); - it('should deny request when day limit exceeded', async () => { + it("should deny request when day limit exceeded", async () => { mockRedisClient.get.mockImplementation((key: string) => { - if (key.includes('minute')) return Promise.resolve('5'); - if (key.includes('hour')) return Promise.resolve('50'); - if (key.includes('day')) return Promise.resolve('500'); // At limit - return Promise.resolve('0'); + if (key.includes("minute")) return Promise.resolve("5"); + if (key.includes("hour")) return Promise.resolve("50"); + if (key.includes("day")) return Promise.resolve("500"); // At limit + return Promise.resolve("0"); }); - const result = await service.checkLimit('test-api-key'); + const result = await service.checkLimit("test-api-key"); expect(result.allowed).toBe(false); - expect(result.window).toBe('day'); + expect(result.window).toBe("day"); }); - it('should allow all requests when rate limiting is disabled', async () => { + it("should allow all requests when rate limiting is disabled", async () => { const disabledConfig = { ...mockConfig, enabled: false }; - + const module: TestingModule = await Test.createTestingModule({ providers: [ RateLimitService, { - provide: 'RATE_LIMIT_CONFIG', + provide: "RATE_LIMIT_CONFIG", useValue: disabledConfig, }, { @@ -150,127 +154,133 @@ describe('RateLimitService', () => { }).compile(); const disabledService = module.get(RateLimitService); - const result = await disabledService.checkLimit('test-api-key'); + const result = await disabledService.checkLimit("test-api-key"); expect(result.allowed).toBe(true); expect(result.limit).toBe(Infinity); }); - it('should handle Redis outage gracefully', async () => { + it("should handle Redis outage gracefully", async () => { mockRedisService.isReady.mockReturnValue(false); - const result = await service.checkLimit('test-api-key'); + const result = await service.checkLimit("test-api-key"); expect(result.allowed).toBe(true); - expect(result.limit).toBe(DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute); + expect(result.limit).toBe( + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute, + ); }); }); - describe('incrementCounter', () => { - it('should increment counters for all windows', async () => { - await service.incrementCounter('test-api-key'); + describe("incrementCounter", () => { + it("should increment counters for all windows", async () => { + await service.incrementCounter("test-api-key"); expect(mockPipeline.incr).toHaveBeenCalledTimes(3); // minute, hour, day expect(mockPipeline.expire).toHaveBeenCalledTimes(3); expect(mockPipeline.exec).toHaveBeenCalled(); }); - it('should not fail when Redis is unavailable', async () => { + it("should not fail when Redis is unavailable", async () => { mockRedisService.isReady.mockReturnValue(false); - await expect(service.incrementCounter('test-api-key')).resolves.not.toThrow(); + await expect( + service.incrementCounter("test-api-key"), + ).resolves.not.toThrow(); }); }); - describe('getUsage', () => { - it('should return usage statistics', async () => { + describe("getUsage", () => { + it("should return usage statistics", async () => { mockRedisClient.get.mockImplementation((key: string) => { - if (key.includes('minute')) return Promise.resolve('5'); - if (key.includes('hour')) return Promise.resolve('50'); - if (key.includes('day')) return Promise.resolve('200'); - return Promise.resolve('0'); + if (key.includes("minute")) return Promise.resolve("5"); + if (key.includes("hour")) return Promise.resolve("50"); + if (key.includes("day")) return Promise.resolve("200"); + return Promise.resolve("0"); }); mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-api-key', + apiKey: "test-api-key", tier: TierPlan.STANDARD, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); - const usage = await service.getUsage('test-api-key'); + const usage = await service.getUsage("test-api-key"); expect(usage).not.toBeNull(); - expect(usage!.apiKey).toBe('test-api-key'); + expect(usage!.apiKey).toBe("test-api-key"); expect(usage!.tier).toBe(TierPlan.STANDARD); expect(usage!.minute.used).toBe(5); expect(usage!.hour.used).toBe(50); expect(usage!.day.used).toBe(200); }); - it('should return null for unknown API key when Redis unavailable', async () => { + it("should return null for unknown API key when Redis unavailable", async () => { mockRedisService.isReady.mockReturnValue(false); - const usage = await service.getUsage('unknown-key'); + const usage = await service.getUsage("unknown-key"); expect(usage).toBeNull(); }); }); - describe('resetCounter', () => { - it('should delete all window keys', async () => { + describe("resetCounter", () => { + it("should delete all window keys", async () => { mockRedisClient.del.mockResolvedValue(3); - await service.resetCounter('test-api-key'); + await service.resetCounter("test-api-key"); expect(mockRedisClient.del).toHaveBeenCalled(); }); - it('should throw error when Redis is unavailable', async () => { + it("should throw error when Redis is unavailable", async () => { mockRedisService.isReady.mockReturnValue(false); - await expect(service.resetCounter('test-api-key')).rejects.toThrow('Redis unavailable'); + await expect(service.resetCounter("test-api-key")).rejects.toThrow( + "Redis unavailable", + ); }); }); - describe('updateQuota', () => { - it('should update quota configuration', async () => { + describe("updateQuota", () => { + it("should update quota configuration", async () => { const newQuota: Partial = { requestsPerMinute: 20, requestsPerHour: 200, }; mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-api-key', + apiKey: "test-api-key", tier: TierPlan.FREE, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); - await service.updateQuota('test-api-key', newQuota); + await service.updateQuota("test-api-key", newQuota); expect(mockRedisClient.hset).toHaveBeenCalled(); }); - it('should throw error when Redis is unavailable', async () => { + it("should throw error when Redis is unavailable", async () => { mockRedisService.isReady.mockReturnValue(false); await expect( - service.updateQuota('test-api-key', { requestsPerMinute: 20 }), - ).rejects.toThrow('Redis unavailable'); + service.updateQuota("test-api-key", { requestsPerMinute: 20 }), + ).rejects.toThrow("Redis unavailable"); }); }); - describe('setTier', () => { - it('should set tier for API key', async () => { + describe("setTier", () => { + it("should set tier for API key", async () => { mockRedisClient.hgetall.mockResolvedValue({ - apiKey: 'test-api-key', + apiKey: "test-api-key", tier: TierPlan.FREE, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", }); - await service.setTier('test-api-key', TierPlan.PREMIUM); + await service.setTier("test-api-key", TierPlan.PREMIUM); expect(mockRedisClient.hset).toHaveBeenCalled(); const hsetCall = mockRedisClient.hset.mock.calls[0]; @@ -278,23 +288,32 @@ describe('RateLimitService', () => { }); }); - describe('tier quotas', () => { - it('should use FREE tier quotas by default', async () => { + describe("tier quotas", () => { + it("should use FREE tier quotas by default", async () => { mockRedisClient.hgetall.mockResolvedValue({}); // No config found - mockRedisClient.get.mockResolvedValue('0'); + mockRedisClient.get.mockResolvedValue("0"); - const result = await service.checkLimit('new-api-key'); + const result = await service.checkLimit("new-api-key"); - expect(result.limit).toBe(DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute); + expect(result.limit).toBe( + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute, + ); }); - it('should use correct quotas for each tier', () => { - const tiers = [TierPlan.FREE, TierPlan.STANDARD, TierPlan.PREMIUM, TierPlan.ENTERPRISE]; - - tiers.forEach(tier => { + it("should use correct quotas for each tier", () => { + const tiers = [ + TierPlan.FREE, + TierPlan.STANDARD, + TierPlan.PREMIUM, + TierPlan.ENTERPRISE, + ]; + + tiers.forEach((tier) => { const quotas = DEFAULT_TIER_QUOTAS[tier]; expect(quotas.requestsPerMinute).toBeGreaterThan(0); - expect(quotas.requestsPerHour).toBeGreaterThan(quotas.requestsPerMinute); + expect(quotas.requestsPerHour).toBeGreaterThan( + quotas.requestsPerMinute, + ); expect(quotas.requestsPerDay).toBeGreaterThan(quotas.requestsPerHour); }); }); diff --git a/apps/api/src/rate-limiting/config/rate-limit.config.ts b/apps/api/src/rate-limiting/config/rate-limit.config.ts index 22ab4b7..3def2e2 100644 --- a/apps/api/src/rate-limiting/config/rate-limit.config.ts +++ b/apps/api/src/rate-limiting/config/rate-limit.config.ts @@ -1,10 +1,10 @@ /** * Rate Limiting Configuration - * + * * Environment-based configuration for Redis connection and rate limiting settings. */ -import { TierPlan, DEFAULT_TIER_QUOTAS } from '../schemas/rate-limit.schema'; +import { TierPlan, DEFAULT_TIER_QUOTAS } from "../schemas/rate-limit.schema"; export interface RateLimitConfig { redis: { @@ -16,25 +16,28 @@ export interface RateLimitConfig { enableReadyCheck: boolean; maxRetriesPerRequest: number; }; - fallbackMode: 'permissive' | 'strict'; + fallbackMode: "permissive" | "strict"; defaultTier: TierPlan; enabled: boolean; } export const rateLimitConfig = (): RateLimitConfig => ({ redis: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT || "6379", 10), password: process.env.REDIS_PASSWORD, - db: parseInt(process.env.REDIS_DB || '0', 10), - keyPrefix: process.env.REDIS_KEY_PREFIX || 'gasguard:', + db: parseInt(process.env.REDIS_DB || "0", 10), + keyPrefix: process.env.REDIS_KEY_PREFIX || "gasguard:", enableReadyCheck: true, maxRetriesPerRequest: 3, }, - fallbackMode: (process.env.RATE_LIMIT_FALLBACK_MODE as 'permissive' | 'strict') || 'permissive', - defaultTier: (process.env.RATE_LIMIT_TIER_DEFAULT as TierPlan) || TierPlan.FREE, - enabled: process.env.RATE_LIMIT_ENABLED !== 'false', + fallbackMode: + (process.env.RATE_LIMIT_FALLBACK_MODE as "permissive" | "strict") || + "permissive", + defaultTier: + (process.env.RATE_LIMIT_TIER_DEFAULT as TierPlan) || TierPlan.FREE, + enabled: process.env.RATE_LIMIT_ENABLED !== "false", }); export { TierPlan, DEFAULT_TIER_QUOTAS }; diff --git a/apps/api/src/rate-limiting/controllers/admin.controller.ts b/apps/api/src/rate-limiting/controllers/admin.controller.ts index 7bd83fe..6db0c31 100644 --- a/apps/api/src/rate-limiting/controllers/admin.controller.ts +++ b/apps/api/src/rate-limiting/controllers/admin.controller.ts @@ -1,6 +1,6 @@ /** * Rate Limit Admin Controller - * + * * Admin endpoints for managing API key quotas and viewing usage statistics. * All endpoints are prefixed with /admin/api-keys */ @@ -16,10 +16,15 @@ import { HttpStatus, Logger, Version, -} from '@nestjs/common'; -import { RateLimitService } from '../services/rate-limit.service'; -import { RedisService } from '../services/redis.service'; -import { QuotaConfig, TierPlan, UsageStats, MAX_TRANSACTION_LIMITS } from '../schemas/rate-limit.schema'; +} from "@nestjs/common"; +import { RateLimitService } from "../services/rate-limit.service"; +import { RedisService } from "../services/redis.service"; +import { + QuotaConfig, + TierPlan, + UsageStats, + MAX_TRANSACTION_LIMITS, +} from "../schemas/rate-limit.schema"; interface UpdateQuotaDto { requestsPerMinute?: number; @@ -41,7 +46,7 @@ interface ResetResponse { resetAt: string; } -@Controller('admin/api-keys') +@Controller("admin/api-keys") export class RateLimitAdminController { private readonly logger = new Logger(RateLimitAdminController.name); @@ -54,17 +59,17 @@ export class RateLimitAdminController { * Get usage statistics for an API key * GET /admin/api-keys/:key/usage */ - @Version('1') - @Get(':key/usage') - async getUsage(@Param('key') apiKey: string): Promise { + @Version("1") + @Get(":key/usage") + async getUsage(@Param("key") apiKey: string): Promise { this.logger.log(`Getting usage for API key: ${apiKey}`); // Check Redis availability if (!this.redisService.isReady()) { throw new HttpException( { - error: 'Service Unavailable', - message: 'Rate limiting service temporarily unavailable', + error: "Service Unavailable", + message: "Rate limiting service temporarily unavailable", }, HttpStatus.SERVICE_UNAVAILABLE, ); @@ -90,10 +95,10 @@ export class RateLimitAdminController { * Update quota for an API key * POST /admin/api-keys/:key/quota */ - @Version('1') - @Post(':key/quota') + @Version("1") + @Post(":key/quota") async updateQuota( - @Param('key') apiKey: string, + @Param("key") apiKey: string, @Body() dto: UpdateQuotaDto, ): Promise { this.logger.log(`Updating quota for API key: ${apiKey}`); @@ -102,19 +107,25 @@ export class RateLimitAdminController { if (!this.redisService.isReady()) { throw new HttpException( { - error: 'Service Unavailable', - message: 'Rate limiting service temporarily unavailable', + error: "Service Unavailable", + message: "Rate limiting service temporarily unavailable", }, HttpStatus.SERVICE_UNAVAILABLE, ); } // Validate input - if (!dto || (!dto.requestsPerMinute && !dto.requestsPerHour && !dto.requestsPerDay && !dto.tier)) { + if ( + !dto || + (!dto.requestsPerMinute && + !dto.requestsPerHour && + !dto.requestsPerDay && + !dto.tier) + ) { throw new HttpException( { - error: 'Invalid Request', - message: 'At least one quota field or tier must be provided', + error: "Invalid Request", + message: "At least one quota field or tier must be provided", }, HttpStatus.BAD_REQUEST, ); @@ -122,12 +133,15 @@ export class RateLimitAdminController { // Validate quota values const quota: Partial = {}; - + if (dto.requestsPerMinute !== undefined) { - if (dto.requestsPerMinute < 1 || dto.requestsPerMinute > MAX_TRANSACTION_LIMITS.requestsPerMinute) { + if ( + dto.requestsPerMinute < 1 || + dto.requestsPerMinute > MAX_TRANSACTION_LIMITS.requestsPerMinute + ) { throw new HttpException( { - error: 'Invalid Request', + error: "Invalid Request", message: `requestsPerMinute must be between 1 and ${MAX_TRANSACTION_LIMITS.requestsPerMinute}`, }, HttpStatus.BAD_REQUEST, @@ -137,10 +151,13 @@ export class RateLimitAdminController { } if (dto.requestsPerHour !== undefined) { - if (dto.requestsPerHour < 1 || dto.requestsPerHour > MAX_TRANSACTION_LIMITS.requestsPerHour) { + if ( + dto.requestsPerHour < 1 || + dto.requestsPerHour > MAX_TRANSACTION_LIMITS.requestsPerHour + ) { throw new HttpException( { - error: 'Invalid Request', + error: "Invalid Request", message: `requestsPerHour must be between 1 and ${MAX_TRANSACTION_LIMITS.requestsPerHour}`, }, HttpStatus.BAD_REQUEST, @@ -150,10 +167,13 @@ export class RateLimitAdminController { } if (dto.requestsPerDay !== undefined) { - if (dto.requestsPerDay < 1 || dto.requestsPerDay > MAX_TRANSACTION_LIMITS.requestsPerDay) { + if ( + dto.requestsPerDay < 1 || + dto.requestsPerDay > MAX_TRANSACTION_LIMITS.requestsPerDay + ) { throw new HttpException( { - error: 'Invalid Request', + error: "Invalid Request", message: `requestsPerDay must be between 1 and ${MAX_TRANSACTION_LIMITS.requestsPerDay}`, }, HttpStatus.BAD_REQUEST, @@ -168,8 +188,8 @@ export class RateLimitAdminController { if (!validTiers.includes(dto.tier)) { throw new HttpException( { - error: 'Invalid Request', - message: `tier must be one of: ${validTiers.join(', ')}`, + error: "Invalid Request", + message: `tier must be one of: ${validTiers.join(", ")}`, }, HttpStatus.BAD_REQUEST, ); @@ -184,7 +204,7 @@ export class RateLimitAdminController { // Get updated usage to return current quota const usage = await this.rateLimitService.getUsage(apiKey); - + return { apiKey, quota: { @@ -201,17 +221,17 @@ export class RateLimitAdminController { * Reset counters for an API key * DELETE /admin/api-keys/:key/reset */ - @Version('1') - @Delete(':key/reset') - async resetCounter(@Param('key') apiKey: string): Promise { + @Version("1") + @Delete(":key/reset") + async resetCounter(@Param("key") apiKey: string): Promise { this.logger.log(`Resetting counters for API key: ${apiKey}`); // Check Redis availability if (!this.redisService.isReady()) { throw new HttpException( { - error: 'Service Unavailable', - message: 'Rate limiting service temporarily unavailable', + error: "Service Unavailable", + message: "Rate limiting service temporarily unavailable", }, HttpStatus.SERVICE_UNAVAILABLE, ); @@ -221,7 +241,7 @@ export class RateLimitAdminController { return { apiKey, - message: 'Rate limit counters have been reset successfully', + message: "Rate limit counters have been reset successfully", resetAt: new Date().toISOString(), }; } diff --git a/apps/api/src/rate-limiting/guards/rate-limit.guard.ts b/apps/api/src/rate-limiting/guards/rate-limit.guard.ts index 8440fb7..36c78df 100644 --- a/apps/api/src/rate-limiting/guards/rate-limit.guard.ts +++ b/apps/api/src/rate-limiting/guards/rate-limit.guard.ts @@ -1,6 +1,6 @@ /** * Rate Limit Guard - * + * * NestJS guard that enforces rate limits on incoming requests. * Extracts API key from X-API-Key header and applies rate limiting checks. * Sets standard rate limit headers on responses. @@ -14,12 +14,12 @@ import { HttpStatus, Logger, Inject, -} from '@nestjs/common'; -import { Request, Response } from 'express'; -import { RateLimitService } from '../services/rate-limit.service'; -import { RedisService } from '../services/redis.service'; -import { RateLimitConfig } from '../config/rate-limit.config'; -import { RATE_LIMIT_HEADERS } from '../schemas/rate-limit.schema'; +} from "@nestjs/common"; +import { Request, Response } from "express"; +import { RateLimitService } from "../services/rate-limit.service"; +import { RedisService } from "../services/redis.service"; +import { RateLimitConfig } from "../config/rate-limit.config"; +import { RATE_LIMIT_HEADERS } from "../schemas/rate-limit.schema"; interface RequestWithApiKey extends Request { apiKey?: string; @@ -32,7 +32,7 @@ export class RateLimitGuard implements CanActivate { constructor( private readonly rateLimitService: RateLimitService, private readonly redisService: RedisService, - @Inject('RATE_LIMIT_CONFIG') + @Inject("RATE_LIMIT_CONFIG") private readonly config: RateLimitConfig, ) {} @@ -42,21 +42,25 @@ export class RateLimitGuard implements CanActivate { // Extract API key from header const apiKey = this.extractApiKey(request); - + if (!apiKey) { // No API key provided - reject in strict mode, allow in permissive - if (this.config.fallbackMode === 'strict') { + if (this.config.fallbackMode === "strict") { throw new HttpException( { - error: 'Missing API Key', - message: 'X-API-Key header is required', + error: "Missing API Key", + message: "X-API-Key header is required", }, HttpStatus.UNAUTHORIZED, ); } - + // In permissive mode, allow but don't track - this.setHeaders(response, { limit: Infinity, remaining: Infinity, resetTime: 0 }); + this.setHeaders(response, { + limit: Infinity, + remaining: Infinity, + resetTime: 0, + }); return true; } @@ -65,21 +69,25 @@ export class RateLimitGuard implements CanActivate { // Check Redis availability if (!this.redisService.isReady()) { - this.logger.warn('Redis unavailable, applying fallback mode'); - - if (this.config.fallbackMode === 'strict') { + this.logger.warn("Redis unavailable, applying fallback mode"); + + if (this.config.fallbackMode === "strict") { throw new HttpException( { - error: 'Service Unavailable', - message: 'Rate limiting service temporarily unavailable', + error: "Service Unavailable", + message: "Rate limiting service temporarily unavailable", }, HttpStatus.SERVICE_UNAVAILABLE, ); } - + // Permissive mode: allow request but log this.logger.warn(`Rate limit check bypassed for API key: ${apiKey}`); - this.setHeaders(response, { limit: Infinity, remaining: Infinity, resetTime: 0 }); + this.setHeaders(response, { + limit: Infinity, + remaining: Infinity, + resetTime: 0, + }); return true; } @@ -95,10 +103,10 @@ export class RateLimitGuard implements CanActivate { if (!status.allowed) { const retryAfter = status.resetTime - Math.floor(Date.now() / 1000); - + throw new HttpException( { - error: 'Rate limit exceeded', + error: "Rate limit exceeded", message: `You have exceeded your request quota for the ${status.window} window. Try again in ${retryAfter} seconds.`, retryAfter, }, @@ -117,14 +125,14 @@ export class RateLimitGuard implements CanActivate { */ private extractApiKey(request: Request): string | null { // Check X-API-Key header (primary) - const apiKey = request.headers['x-api-key']; - if (apiKey && typeof apiKey === 'string') { + const apiKey = request.headers["x-api-key"]; + if (apiKey && typeof apiKey === "string") { return apiKey.trim(); } // Check Authorization header as fallback (Bearer token format) const authHeader = request.headers.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { + if (authHeader && authHeader.startsWith("Bearer ")) { return authHeader.substring(7).trim(); } @@ -139,7 +147,10 @@ export class RateLimitGuard implements CanActivate { params: { limit: number; remaining: number; resetTime: number }, ): void { response.setHeader(RATE_LIMIT_HEADERS.limit, params.limit.toString()); - response.setHeader(RATE_LIMIT_HEADERS.remaining, params.remaining.toString()); + response.setHeader( + RATE_LIMIT_HEADERS.remaining, + params.remaining.toString(), + ); response.setHeader(RATE_LIMIT_HEADERS.reset, params.resetTime.toString()); } } diff --git a/apps/api/src/rate-limiting/index.ts b/apps/api/src/rate-limiting/index.ts index 127782d..d14171a 100644 --- a/apps/api/src/rate-limiting/index.ts +++ b/apps/api/src/rate-limiting/index.ts @@ -1,24 +1,27 @@ /** * Rate Limiting Module Public API - * + * * Export all public types, services, and guards for the rate limiting system. */ // Services -export { RateLimitService } from './services/rate-limit.service'; -export { RedisService } from './services/redis.service'; +export { RateLimitService } from "./services/rate-limit.service"; +export { RedisService } from "./services/redis.service"; // Guards -export { RateLimitGuard } from './guards/rate-limit.guard'; +export { RateLimitGuard } from "./guards/rate-limit.guard"; // Controllers -export { RateLimitAdminController } from './controllers/admin.controller'; +export { RateLimitAdminController } from "./controllers/admin.controller"; // Module -export { RateLimitingModule, RateLimitingModuleOptions } from './rate-limiting.module'; +export { + RateLimitingModule, + RateLimitingModuleOptions, +} from "./rate-limiting.module"; // Config -export { RateLimitConfig, rateLimitConfig } from './config/rate-limit.config'; +export { RateLimitConfig, rateLimitConfig } from "./config/rate-limit.config"; // Schemas and Types export { @@ -32,4 +35,4 @@ export { WINDOW_DURATIONS, REDIS_KEY_PREFIXES, RATE_LIMIT_HEADERS, -} from './schemas/rate-limit.schema'; +} from "./schemas/rate-limit.schema"; diff --git a/apps/api/src/rate-limiting/rate-limiting.module.ts b/apps/api/src/rate-limiting/rate-limiting.module.ts index f2f23b1..3967d3f 100644 --- a/apps/api/src/rate-limiting/rate-limiting.module.ts +++ b/apps/api/src/rate-limiting/rate-limiting.module.ts @@ -1,16 +1,16 @@ /** * Rate Limiting Module - * + * * NestJS module that provides rate limiting capabilities including * Redis service, rate limit service, guard, and admin controller. */ -import { Module, Global, DynamicModule } from '@nestjs/common'; -import { RateLimitService } from './services/rate-limit.service'; -import { RedisService } from './services/redis.service'; -import { RateLimitGuard } from './guards/rate-limit.guard'; -import { RateLimitAdminController } from './controllers/admin.controller'; -import { RateLimitConfig, rateLimitConfig } from './config/rate-limit.config'; +import { Module, Global, DynamicModule } from "@nestjs/common"; +import { RateLimitService } from "./services/rate-limit.service"; +import { RedisService } from "./services/redis.service"; +import { RateLimitGuard } from "./guards/rate-limit.guard"; +import { RateLimitAdminController } from "./controllers/admin.controller"; +import { RateLimitConfig, rateLimitConfig } from "./config/rate-limit.config"; export interface RateLimitingModuleOptions { config?: Partial; @@ -38,7 +38,7 @@ export class RateLimitingModule { controllers: [RateLimitAdminController], providers: [ { - provide: 'RATE_LIMIT_CONFIG', + provide: "RATE_LIMIT_CONFIG", useValue: config, }, RedisService, @@ -53,7 +53,9 @@ export class RateLimitingModule { * Register the rate limiting module asynchronously */ static forRootAsync(options: { - useFactory: (...args: any[]) => Promise> | Partial; + useFactory: ( + ...args: any[] + ) => Promise> | Partial; inject?: any[]; }): DynamicModule { return { @@ -61,7 +63,7 @@ export class RateLimitingModule { controllers: [RateLimitAdminController], providers: [ { - provide: 'RATE_LIMIT_CONFIG', + provide: "RATE_LIMIT_CONFIG", useFactory: async (...args: any[]) => { const defaultConfig = rateLimitConfig(); const customConfig = await options.useFactory(...args); diff --git a/apps/api/src/rate-limiting/schemas/rate-limit.schema.ts b/apps/api/src/rate-limiting/schemas/rate-limit.schema.ts index f7f8504..a7a7e81 100644 --- a/apps/api/src/rate-limiting/schemas/rate-limit.schema.ts +++ b/apps/api/src/rate-limiting/schemas/rate-limit.schema.ts @@ -1,15 +1,15 @@ /** * Rate Limiting Schemas and Types - * + * * Defines the data structures for rate limiting including quotas, * usage statistics, and tier configurations. */ export enum TierPlan { - FREE = 'free', - STANDARD = 'standard', - PREMIUM = 'premium', - ENTERPRISE = 'enterprise', + FREE = "free", + STANDARD = "standard", + PREMIUM = "premium", + ENTERPRISE = "enterprise", } export interface QuotaConfig { @@ -23,7 +23,7 @@ export interface RateLimitStatus { limit: number; remaining: number; resetTime: number; - window: 'minute' | 'hour' | 'day'; + window: "minute" | "hour" | "day"; } export interface UsageStats { @@ -101,14 +101,14 @@ export const WINDOW_DURATIONS = { // Redis key prefixes export const REDIS_KEY_PREFIXES = { - rateLimit: 'ratelimit', - apiKeyConfig: 'apikey:config', + rateLimit: "ratelimit", + apiKeyConfig: "apikey:config", }; // Header names export const RATE_LIMIT_HEADERS = { - limit: 'X-RateLimit-Limit', - remaining: 'X-RateLimit-Remaining', - reset: 'X-RateLimit-Reset', - retryAfter: 'Retry-After', + limit: "X-RateLimit-Limit", + remaining: "X-RateLimit-Remaining", + reset: "X-RateLimit-Reset", + retryAfter: "Retry-After", }; diff --git a/apps/api/src/rate-limiting/services/rate-limit.service.ts b/apps/api/src/rate-limiting/services/rate-limit.service.ts index 340dc08..084cfd0 100644 --- a/apps/api/src/rate-limiting/services/rate-limit.service.ts +++ b/apps/api/src/rate-limiting/services/rate-limit.service.ts @@ -1,12 +1,12 @@ /** * Rate Limit Service - * + * * Core rate limiting logic using sliding window algorithm. * Tracks per-API key request counts across minute, hour, and day windows. */ -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { RedisService } from './redis.service'; +import { Injectable, Logger, Inject } from "@nestjs/common"; +import { RedisService } from "./redis.service"; import { TierPlan, QuotaConfig, @@ -16,11 +16,11 @@ import { DEFAULT_TIER_QUOTAS, WINDOW_DURATIONS, REDIS_KEY_PREFIXES, -} from '../schemas/rate-limit.schema'; -import { RateLimitConfig } from '../config/rate-limit.config'; +} from "../schemas/rate-limit.schema"; +import { RateLimitConfig } from "../config/rate-limit.config"; interface WindowCheck { - window: 'minute' | 'hour' | 'day'; + window: "minute" | "hour" | "day"; limit: number; duration: number; } @@ -31,7 +31,7 @@ export class RateLimitService { constructor( private readonly redisService: RedisService, - @Inject('RATE_LIMIT_CONFIG') + @Inject("RATE_LIMIT_CONFIG") private readonly config: RateLimitConfig, ) {} @@ -41,14 +41,32 @@ export class RateLimitService { */ async checkLimit(apiKey: string): Promise { if (!this.config.enabled) { - return { allowed: true, limit: Infinity, remaining: Infinity, resetTime: 0, window: 'minute' }; + return { + allowed: true, + limit: Infinity, + remaining: Infinity, + resetTime: 0, + window: "minute", + }; } const quota = await this.getQuotaForKey(apiKey); const windows: WindowCheck[] = [ - { window: 'minute', limit: quota.requestsPerMinute, duration: WINDOW_DURATIONS.minute }, - { window: 'hour', limit: quota.requestsPerHour, duration: WINDOW_DURATIONS.hour }, - { window: 'day', limit: quota.requestsPerDay, duration: WINDOW_DURATIONS.day }, + { + window: "minute", + limit: quota.requestsPerMinute, + duration: WINDOW_DURATIONS.minute, + }, + { + window: "hour", + limit: quota.requestsPerHour, + duration: WINDOW_DURATIONS.hour, + }, + { + window: "day", + limit: quota.requestsPerDay, + duration: WINDOW_DURATIONS.day, + }, ]; // Check all windows and find the most restrictive @@ -69,8 +87,12 @@ export class RateLimitService { // All windows have capacity - return the most restrictive remaining const mostRestrictive = windows[0]; // minute window - const count = await this.getWindowCount(apiKey, mostRestrictive.window, mostRestrictive.duration); - + const count = await this.getWindowCount( + apiKey, + mostRestrictive.window, + mostRestrictive.duration, + ); + return { allowed: true, limit: mostRestrictive.limit, @@ -85,14 +107,14 @@ export class RateLimitService { */ async incrementCounter(apiKey: string): Promise { if (!this.redisService.isReady()) { - this.logger.warn('Redis unavailable, skipping counter increment'); + this.logger.warn("Redis unavailable, skipping counter increment"); return; } - const windows: { window: 'minute' | 'hour' | 'day'; duration: number }[] = [ - { window: 'minute', duration: WINDOW_DURATIONS.minute }, - { window: 'hour', duration: WINDOW_DURATIONS.hour }, - { window: 'day', duration: WINDOW_DURATIONS.day }, + const windows: { window: "minute" | "hour" | "day"; duration: number }[] = [ + { window: "minute", duration: WINDOW_DURATIONS.minute }, + { window: "hour", duration: WINDOW_DURATIONS.hour }, + { window: "day", duration: WINDOW_DURATIONS.day }, ]; const client = this.redisService.getClient()!; @@ -106,12 +128,12 @@ export class RateLimitService { // Update last request timestamp const configKey = `${REDIS_KEY_PREFIXES.apiKeyConfig}:${apiKey}`; - pipeline.hset(configKey, 'lastRequestAt', new Date().toISOString()); + pipeline.hset(configKey, "lastRequestAt", new Date().toISOString()); try { await pipeline.exec(); } catch (error) { - this.logger.error('Failed to increment counters:', error.message); + this.logger.error("Failed to increment counters:", error.message); } } @@ -127,9 +149,9 @@ export class RateLimitService { const quota = config.customQuota || DEFAULT_TIER_QUOTAS[config.tier]; const [minuteCount, hourCount, dayCount] = await Promise.all([ - this.getWindowCount(apiKey, 'minute', WINDOW_DURATIONS.minute), - this.getWindowCount(apiKey, 'hour', WINDOW_DURATIONS.hour), - this.getWindowCount(apiKey, 'day', WINDOW_DURATIONS.day), + this.getWindowCount(apiKey, "minute", WINDOW_DURATIONS.minute), + this.getWindowCount(apiKey, "hour", WINDOW_DURATIONS.hour), + this.getWindowCount(apiKey, "day", WINDOW_DURATIONS.day), ]); return { @@ -159,14 +181,14 @@ export class RateLimitService { */ async resetCounter(apiKey: string): Promise { if (!this.redisService.isReady()) { - throw new Error('Redis unavailable'); + throw new Error("Redis unavailable"); } const client = this.redisService.getClient()!; - const windows: ('minute' | 'hour' | 'day')[] = ['minute', 'hour', 'day']; + const windows: ("minute" | "hour" | "day")[] = ["minute", "hour", "day"]; const keysToDelete: string[] = []; - + for (const window of windows) { const duration = WINDOW_DURATIONS[window]; const key = this.getWindowKey(apiKey, window, duration); @@ -183,9 +205,12 @@ export class RateLimitService { /** * Update quota configuration for an API key */ - async updateQuota(apiKey: string, quota: Partial): Promise { + async updateQuota( + apiKey: string, + quota: Partial, + ): Promise { if (!this.redisService.isReady()) { - throw new Error('Redis unavailable'); + throw new Error("Redis unavailable"); } const client = this.redisService.getClient()!; @@ -194,9 +219,18 @@ export class RateLimitService { // Get existing config or create new const existing = await this.getApiKeyConfig(apiKey); const updatedQuota: QuotaConfig = { - requestsPerMinute: quota.requestsPerMinute ?? existing?.customQuota?.requestsPerMinute ?? DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute, - requestsPerHour: quota.requestsPerHour ?? existing?.customQuota?.requestsPerHour ?? DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerHour, - requestsPerDay: quota.requestsPerDay ?? existing?.customQuota?.requestsPerDay ?? DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerDay, + requestsPerMinute: + quota.requestsPerMinute ?? + existing?.customQuota?.requestsPerMinute ?? + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerMinute, + requestsPerHour: + quota.requestsPerHour ?? + existing?.customQuota?.requestsPerHour ?? + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerHour, + requestsPerDay: + quota.requestsPerDay ?? + existing?.customQuota?.requestsPerDay ?? + DEFAULT_TIER_QUOTAS[TierPlan.FREE].requestsPerDay, }; const config: ApiKeyConfig = { @@ -223,7 +257,7 @@ export class RateLimitService { */ async setTier(apiKey: string, tier: TierPlan): Promise { if (!this.redisService.isReady()) { - throw new Error('Redis unavailable'); + throw new Error("Redis unavailable"); } const client = this.redisService.getClient()!; @@ -241,7 +275,7 @@ export class RateLimitService { await client.hset(configKey, { apiKey: config.apiKey, tier: config.tier, - customQuota: config.customQuota ? JSON.stringify(config.customQuota) : '', + customQuota: config.customQuota ? JSON.stringify(config.customQuota) : "", createdAt: config.createdAt, updatedAt: config.updatedAt, }); @@ -254,7 +288,7 @@ export class RateLimitService { */ private async getQuotaForKey(apiKey: string): Promise { const config = await this.getApiKeyConfig(apiKey); - + if (config?.customQuota) { return config.customQuota; } @@ -266,16 +300,18 @@ export class RateLimitService { /** * Get API key configuration from Redis */ - private async getApiKeyConfig(apiKey: string): Promise<(ApiKeyConfig & { lastRequestAt?: string }) | null> { + private async getApiKeyConfig( + apiKey: string, + ): Promise<(ApiKeyConfig & { lastRequestAt?: string }) | null> { if (!this.redisService.isReady()) { return null; } const client = this.redisService.getClient()!; const configKey = `${REDIS_KEY_PREFIXES.apiKeyConfig}:${apiKey}`; - + const data = await client.hgetall(configKey); - + if (!data || Object.keys(data).length === 0) { return null; } @@ -295,7 +331,7 @@ export class RateLimitService { */ private async getWindowCount( apiKey: string, - window: 'minute' | 'hour' | 'day', + window: "minute" | "hour" | "day", duration: number, ): Promise { if (!this.redisService.isReady()) { @@ -304,15 +340,19 @@ export class RateLimitService { const key = this.getWindowKey(apiKey, window, duration); const client = this.redisService.getClient()!; - + const count = await client.get(key); - return parseInt(count || '0', 10); + return parseInt(count || "0", 10); } /** * Generate Redis key for a specific window */ - private getWindowKey(apiKey: string, window: string, duration: number): string { + private getWindowKey( + apiKey: string, + window: string, + duration: number, + ): string { // Use current timestamp rounded to window boundary for sliding window effect const now = Math.floor(Date.now() / 1000); const windowStart = Math.floor(now / duration) * duration; diff --git a/apps/api/src/rate-limiting/services/redis.service.ts b/apps/api/src/rate-limiting/services/redis.service.ts index eb60697..6d888e8 100644 --- a/apps/api/src/rate-limiting/services/redis.service.ts +++ b/apps/api/src/rate-limiting/services/redis.service.ts @@ -1,13 +1,19 @@ /** * Redis Service - * + * * Manages Redis connection with health checks, reconnection logic, * and graceful fallback handling for outages. */ -import { Injectable, OnModuleInit, OnModuleDestroy, Logger, Inject } from '@nestjs/common'; -import Redis from 'ioredis'; -import { RateLimitConfig } from '../config/rate-limit.config'; +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + Logger, + Inject, +} from "@nestjs/common"; +import Redis from "ioredis"; +import { RateLimitConfig } from "../config/rate-limit.config"; @Injectable() export class RedisService implements OnModuleInit, OnModuleDestroy { @@ -19,7 +25,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { private reconnectTimer: NodeJS.Timeout | null = null; constructor( - @Inject('RATE_LIMIT_CONFIG') + @Inject("RATE_LIMIT_CONFIG") private readonly config: RateLimitConfig, ) {} @@ -52,36 +58,36 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { }); // Set up event handlers - this.client.on('connect', () => { - this.logger.log('Redis client connecting...'); + this.client.on("connect", () => { + this.logger.log("Redis client connecting..."); }); - this.client.on('ready', () => { + this.client.on("ready", () => { this.isConnected = true; this.reconnectAttempts = 0; - this.logger.log('Redis client ready and connected'); + this.logger.log("Redis client ready and connected"); }); - this.client.on('error', (error) => { - this.logger.error('Redis client error:', error.message); + this.client.on("error", (error) => { + this.logger.error("Redis client error:", error.message); this.isConnected = false; }); - this.client.on('close', () => { - this.logger.warn('Redis connection closed'); + this.client.on("close", () => { + this.logger.warn("Redis connection closed"); this.isConnected = false; this.scheduleReconnect(); }); - this.client.on('reconnecting', () => { - this.logger.log('Redis client reconnecting...'); + this.client.on("reconnecting", () => { + this.logger.log("Redis client reconnecting..."); }); // Initial connection await this.client.connect(); this.isConnected = true; } catch (error) { - this.logger.error('Failed to connect to Redis:', error.message); + this.logger.error("Failed to connect to Redis:", error.message); this.isConnected = false; this.scheduleReconnect(); } @@ -96,17 +102,23 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { } if (this.reconnectAttempts >= this.maxReconnectAttempts) { - this.logger.error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`); + this.logger.error( + `Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`, + ); return; } this.reconnectAttempts++; const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); - - this.logger.log(`Scheduling Redis reconnect attempt ${this.reconnectAttempts} in ${delay}ms`); - + + this.logger.log( + `Scheduling Redis reconnect attempt ${this.reconnectAttempts} in ${delay}ms`, + ); + this.reconnectTimer = setTimeout(() => { - this.logger.log(`Attempting Redis reconnect ${this.reconnectAttempts}...`); + this.logger.log( + `Attempting Redis reconnect ${this.reconnectAttempts}...`, + ); this.connect(); }, delay); } @@ -124,7 +136,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { await this.client.quit(); this.client = null; this.isConnected = false; - this.logger.log('Redis client disconnected'); + this.logger.log("Redis client disconnected"); } } @@ -132,7 +144,9 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { * Check if Redis is connected and ready */ isReady(): boolean { - return this.isConnected && this.client !== null && this.client.status === 'ready'; + return ( + this.isConnected && this.client !== null && this.client.status === "ready" + ); } /** @@ -147,16 +161,18 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { * Execute a Redis command with fallback handling * Returns null if Redis is unavailable and fallback is permissive */ - async execute(operation: (client: Redis) => Promise): Promise { + async execute( + operation: (client: Redis) => Promise, + ): Promise { if (!this.isReady()) { - this.logger.warn('Redis not available, operation skipped'); + this.logger.warn("Redis not available, operation skipped"); return null; } try { return await operation(this.client!); } catch (error) { - this.logger.error('Redis operation failed:', error.message); + this.logger.error("Redis operation failed:", error.message); return null; } } @@ -164,18 +180,22 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { /** * Health check for Redis connection */ - async healthCheck(): Promise<{ status: string; connected: boolean; latency?: number }> { + async healthCheck(): Promise<{ + status: string; + connected: boolean; + latency?: number; + }> { if (!this.isReady()) { - return { status: 'disconnected', connected: false }; + return { status: "disconnected", connected: false }; } const start = Date.now(); try { await this.client!.ping(); const latency = Date.now() - start; - return { status: 'healthy', connected: true, latency }; + return { status: "healthy", connected: true, latency }; } catch (error) { - return { status: 'unhealthy', connected: false }; + return { status: "unhealthy", connected: false }; } } } diff --git a/apps/api/src/recommendation/routes/stellar/engine.ts b/apps/api/src/recommendation/routes/stellar/engine.ts index 4793644..806dd13 100644 --- a/apps/api/src/recommendation/routes/stellar/engine.ts +++ b/apps/api/src/recommendation/routes/stellar/engine.ts @@ -4,7 +4,7 @@ import { scoreRoutes } from "./scorer"; export class StellarRouteRecommendationEngine { recommend( routes: StellarRoute[], - options?: RouteRecommendationOptions + options?: RouteRecommendationOptions, ): ScoredRoute[] { if (!routes.length) return []; @@ -15,9 +15,9 @@ export class StellarRouteRecommendationEngine { getBestRoute( routes: StellarRoute[], - options?: RouteRecommendationOptions + options?: RouteRecommendationOptions, ): ScoredRoute | null { const ranked = this.recommend(routes, options); return ranked[0] || null; } -} \ No newline at end of file +} diff --git a/apps/api/src/recommendation/routes/stellar/index.ts b/apps/api/src/recommendation/routes/stellar/index.ts index 01441e6..919c376 100644 --- a/apps/api/src/recommendation/routes/stellar/index.ts +++ b/apps/api/src/recommendation/routes/stellar/index.ts @@ -1,3 +1,3 @@ export * from "./types"; export * from "./engine"; -export * from "./scorer"; \ No newline at end of file +export * from "./scorer"; diff --git a/apps/api/src/recommendation/routes/stellar/scorer.ts b/apps/api/src/recommendation/routes/stellar/scorer.ts index 57b7562..f2e73b3 100644 --- a/apps/api/src/recommendation/routes/stellar/scorer.ts +++ b/apps/api/src/recommendation/routes/stellar/scorer.ts @@ -8,13 +8,13 @@ function normalize(value: number, min: number, max: number) { export function scoreRoutes( routes: StellarRoute[], - options: RouteRecommendationOptions = {} + options: RouteRecommendationOptions = {}, ) { const weights = { ...defaultWeights, ...options }; - const costs = routes.map(r => r.estimatedCost); - const times = routes.map(r => r.estimatedTimeMs); - const reliabilities = routes.map(r => r.reliability ?? 0.5); + const costs = routes.map((r) => r.estimatedCost); + const times = routes.map((r) => r.estimatedTimeMs); + const reliabilities = routes.map((r) => r.reliability ?? 0.5); const minCost = Math.min(...costs); const maxCost = Math.max(...costs); @@ -22,7 +22,7 @@ export function scoreRoutes( const minTime = Math.min(...times); const maxTime = Math.max(...times); - return routes.map(route => { + return routes.map((route) => { const costScore = 1 - normalize(route.estimatedCost, minCost, maxCost); const speedScore = 1 - normalize(route.estimatedTimeMs, minTime, maxTime); const reliabilityScore = route.reliability ?? 0.5; @@ -37,4 +37,4 @@ export function scoreRoutes( score, }; }); -} \ No newline at end of file +} diff --git a/apps/api/src/recommendation/routes/stellar/types.ts b/apps/api/src/recommendation/routes/stellar/types.ts index dff61f5..0876484 100644 --- a/apps/api/src/recommendation/routes/stellar/types.ts +++ b/apps/api/src/recommendation/routes/stellar/types.ts @@ -14,4 +14,4 @@ export type RouteRecommendationOptions = { weightCost?: number; weightSpeed?: number; weightReliability?: number; -}; \ No newline at end of file +}; diff --git a/apps/api/src/recommendation/routes/stellar/weights.ts b/apps/api/src/recommendation/routes/stellar/weights.ts index 630aaa2..8cd1915 100644 --- a/apps/api/src/recommendation/routes/stellar/weights.ts +++ b/apps/api/src/recommendation/routes/stellar/weights.ts @@ -4,4 +4,4 @@ export const defaultWeights: Required = { weightCost: 0.5, weightSpeed: 0.4, weightReliability: 0.1, -}; \ No newline at end of file +}; diff --git a/apps/api/src/routes/analysis.routes.ts b/apps/api/src/routes/analysis.routes.ts index d0d0cc2..0ffd692 100644 --- a/apps/api/src/routes/analysis.routes.ts +++ b/apps/api/src/routes/analysis.routes.ts @@ -1,30 +1,29 @@ -import { Router } from 'express'; -import { AnalysisController } from '../controllers/analysis.controller'; -import { AnalysisValidator } from '../validation/analysis.validator'; +import { Router } from "express"; +import { AnalysisController } from "../controllers/analysis.controller"; +import { AnalysisValidator } from "../validation/analysis.validator"; export function createAnalysisRoutes(queue: any): Router { const router = Router(); const controller = new AnalysisController(queue); // Submit codebase for analysis - router.post('/analysis', - AnalysisValidator.validateSubmission, - (req, res) => controller.submitCodebase(req, res) + router.post("/analysis", AnalysisValidator.validateSubmission, (req, res) => + controller.submitCodebase(req, res), ); // Get analysis status - router.get('/analysis/:id/status', - (req, res) => controller.getAnalysisStatus(req, res) + router.get("/analysis/:id/status", (req, res) => + controller.getAnalysisStatus(req, res), ); // Get analysis result - router.get('/analysis/:id/result', - (req, res) => controller.getAnalysisResult(req, res) + router.get("/analysis/:id/result", (req, res) => + controller.getAnalysisResult(req, res), ); // Cancel analysis - router.delete('/analysis/:id', - (req, res) => controller.cancelAnalysis(req, res) + router.delete("/analysis/:id", (req, res) => + controller.cancelAnalysis(req, res), ); return router; diff --git a/apps/api/src/scan.ts b/apps/api/src/scan.ts index 532937c..b6c31ce 100644 --- a/apps/api/src/scan.ts +++ b/apps/api/src/scan.ts @@ -1,17 +1,21 @@ -type ProgressCb = (p: number) => Promise | void +type ProgressCb = (p: number) => Promise | void; export async function performScan(input: any, onProgress: ProgressCb) { - await onProgress(25) - await sleep(500) - await onProgress(50) - await sleep(500) - await onProgress(75) - await sleep(500) - const result = { summary: 'ok', issues: [], inputSize: JSON.stringify(input || {}).length } - await onProgress(100) - return result + await onProgress(25); + await sleep(500); + await onProgress(50); + await sleep(500); + await onProgress(75); + await sleep(500); + const result = { + summary: "ok", + issues: [], + inputSize: JSON.stringify(input || {}).length, + }; + await onProgress(100); + return result; } function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)) -} \ No newline at end of file + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/api/src/schemas/analysis.schema.ts b/apps/api/src/schemas/analysis.schema.ts index 2f8e20d..b63ccaa 100644 --- a/apps/api/src/schemas/analysis.schema.ts +++ b/apps/api/src/schemas/analysis.schema.ts @@ -13,29 +13,29 @@ export interface CodebaseSubmissionRequest { export interface FileSubmission { path: string; content: string; - language: 'rust' | 'typescript' | 'javascript' | 'solidity'; + language: "rust" | "typescript" | "javascript" | "solidity"; size: number; lastModified?: string; } export interface AnalysisOptions { - scanType: 'security' | 'performance' | 'gas-optimization' | 'full'; - severity: 'low' | 'medium' | 'high' | 'critical'; + scanType: "security" | "performance" | "gas-optimization" | "full"; + severity: "low" | "medium" | "high" | "critical"; includeRecommendations: boolean; excludePatterns?: string[]; } export interface ProjectMetadata { - framework: 'soroban' | 'solidity' | 'general'; + framework: "soroban" | "solidity" | "general"; version?: string; dependencies?: Record; - buildSystem?: 'cargo' | 'npm' | 'yarn' | 'hardhat'; - network?: 'stellar' | 'ethereum' | 'polygon' | 'bsc'; + buildSystem?: "cargo" | "npm" | "yarn" | "hardhat"; + network?: "stellar" | "ethereum" | "polygon" | "bsc"; } export interface AnalysisResponse { jobId: string; - status: 'queued' | 'processing' | 'completed' | 'failed'; + status: "queued" | "processing" | "completed" | "failed"; submittedAt: string; estimatedDuration?: number; statusUrl: string; @@ -44,7 +44,7 @@ export interface AnalysisResponse { export interface AnalysisStatus { jobId: string; - status: 'queued' | 'processing' | 'completed' | 'failed'; + status: "queued" | "processing" | "completed" | "failed"; progress: number; currentStep?: string; startedAt?: string; @@ -54,7 +54,7 @@ export interface AnalysisStatus { export interface AnalysisResult { jobId: string; - status: 'completed' | 'failed'; + status: "completed" | "failed"; completedAt: string; duration: number; summary: AnalysisSummary; @@ -83,8 +83,8 @@ export interface FileAnalysisResult { export interface Issue { id: string; - type: 'security' | 'performance' | 'gas-optimization' | 'best-practice'; - severity: 'low' | 'medium' | 'high' | 'critical'; + type: "security" | "performance" | "gas-optimization" | "best-practice"; + severity: "low" | "medium" | "high" | "critical"; title: string; description: string; location: { @@ -113,10 +113,10 @@ export interface Recommendation { id: string; title: string; description: string; - priority: 'low' | 'medium' | 'high'; + priority: "low" | "medium" | "high"; estimatedImpact: string; implementation: { - difficulty: 'easy' | 'medium' | 'hard'; + difficulty: "easy" | "medium" | "hard"; timeEstimate: string; codeChanges?: CodeChange[]; }; @@ -125,7 +125,7 @@ export interface Recommendation { export interface CodeChange { file: string; line: number; - operation: 'replace' | 'insert' | 'delete'; + operation: "replace" | "insert" | "delete"; content: string; } diff --git a/apps/api/src/schemas/cross-chain-gas.schema.ts b/apps/api/src/schemas/cross-chain-gas.schema.ts index 13b7d41..5b9a545 100644 --- a/apps/api/src/schemas/cross-chain-gas.schema.ts +++ b/apps/api/src/schemas/cross-chain-gas.schema.ts @@ -5,7 +5,7 @@ export interface ChainGasMetrics { priorityFee?: string; averageGasUsed: { transfer: number; - 'contract-call': number; + "contract-call": number; swap: number; }; nativeTokenPriceUSD: number; @@ -22,7 +22,7 @@ export interface TransactionCost { } export interface CrossChainGasRequest { - txType: 'transfer' | 'contract-call' | 'swap'; + txType: "transfer" | "contract-call" | "swap"; } export interface CrossChainGasResponse { diff --git a/apps/api/src/schemas/failed-transaction.schema.ts b/apps/api/src/schemas/failed-transaction.schema.ts index d167c70..9c030fb 100644 --- a/apps/api/src/schemas/failed-transaction.schema.ts +++ b/apps/api/src/schemas/failed-transaction.schema.ts @@ -21,18 +21,18 @@ export interface TransactionMetadata { gasLimit: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; - transactionType: 'legacy' | 'eip1559' | 'eip2930'; + transactionType: "legacy" | "eip1559" | "eip2930"; } -export type FailureCategory = - | 'underpriced_gas' - | 'out_of_gas' - | 'contract_revert' - | 'slippage_exceeded' - | 'nonce_conflict' - | 'insufficient_balance' - | 'network_error' - | 'unknown'; +export type FailureCategory = + | "underpriced_gas" + | "out_of_gas" + | "contract_revert" + | "slippage_exceeded" + | "nonce_conflict" + | "insufficient_balance" + | "network_error" + | "unknown"; export interface FailureAnalysis { wallet: string; @@ -59,7 +59,7 @@ export interface ChainFailureStats { export interface MitigationRecommendation { id: string; category: FailureCategory; - priority: 'low' | 'medium' | 'high'; + priority: "low" | "medium" | "high"; title: string; description: string; action: string; @@ -85,7 +85,7 @@ export interface TransactionAnalysisResponse { } export interface FailedTransactionEvent { - type: 'transaction_failed'; + type: "transaction_failed"; data: FailedTransaction; timestamp: string; } @@ -120,7 +120,7 @@ export interface RootCauseAnalysis { }; timing?: { blockTime: number; - congestion: 'low' | 'medium' | 'high'; + congestion: "low" | "medium" | "high"; }; }; } diff --git a/apps/api/src/services/failed-transaction.service.ts b/apps/api/src/services/failed-transaction.service.ts index d6a54e8..e2b71c6 100644 --- a/apps/api/src/services/failed-transaction.service.ts +++ b/apps/api/src/services/failed-transaction.service.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import { - FailedTransaction, - FailureCategory, - TransactionMetadata, +import { Injectable } from "@nestjs/common"; +import { + FailedTransaction, + FailureCategory, + TransactionMetadata, RootCauseAnalysis, - CostMetrics -} from '../schemas/failed-transaction.schema'; + CostMetrics, +} from "../schemas/failed-transaction.schema"; @Injectable() export class FailedTransactionService { @@ -15,28 +15,31 @@ export class FailedTransactionService { /** * Track a failed transaction */ - async trackFailedTransaction(transactionData: Partial): Promise { + async trackFailedTransaction( + transactionData: Partial, + ): Promise { const category = await this.classifyFailure(transactionData); const rootCause = await this.analyzeRootCause(transactionData, category); - + const failedTx: FailedTransaction = { id: this.generateId(), hash: transactionData.hash!, wallet: transactionData.wallet!, chainId: transactionData.chainId!, blockNumber: transactionData.blockNumber, - gasUsed: transactionData.gasUsed || '0', - gasPrice: transactionData.gasPrice || '0', + gasUsed: transactionData.gasUsed || "0", + gasPrice: transactionData.gasPrice || "0", effectiveFee: this.calculateEffectiveFee(transactionData), failureCategory: category, - revertReason: transactionData.revertReason || rootCause.evidence.join('; '), + revertReason: + transactionData.revertReason || rootCause.evidence.join("; "), timestamp: transactionData.timestamp || new Date().toISOString(), - metadata: transactionData.metadata! + metadata: transactionData.metadata!, }; // Store transaction this.failedTransactions.set(failedTx.id, failedTx); - + // Update wallet failures const walletTxs = this.walletFailures.get(failedTx.wallet) || []; walletTxs.push(failedTx); @@ -48,118 +51,139 @@ export class FailedTransactionService { /** * Get failed transactions for a wallet */ - async getWalletFailures(wallet: string, chainIds?: number[]): Promise { + async getWalletFailures( + wallet: string, + chainIds?: number[], + ): Promise { const failures = this.walletFailures.get(wallet) || []; - + if (chainIds && chainIds.length > 0) { - return failures.filter(tx => chainIds.includes(tx.chainId)); + return failures.filter((tx) => chainIds.includes(tx.chainId)); } - + return failures; } /** * Classify the failure category based on transaction data */ - private async classifyFailure(transactionData: Partial): Promise { + private async classifyFailure( + transactionData: Partial, + ): Promise { const { revertReason, metadata, gasUsed, gasPrice } = transactionData; // Check for underpriced gas - if (gasPrice && await this.isUnderpriced(gasPrice, transactionData.chainId!)) { - return 'underpriced_gas'; + if ( + gasPrice && + (await this.isUnderpriced(gasPrice, transactionData.chainId!)) + ) { + return "underpriced_gas"; } // Check for out of gas if (gasUsed && metadata?.gasLimit) { - const utilization = (parseInt(gasUsed) / parseInt(metadata.gasLimit)) * 100; + const utilization = + (parseInt(gasUsed) / parseInt(metadata.gasLimit)) * 100; if (utilization >= 99.5) { - return 'out_of_gas'; + return "out_of_gas"; } } // Check revert reason patterns if (revertReason) { const reason = revertReason.toLowerCase(); - - if (reason.includes('insufficient funds') || reason.includes('balance')) { - return 'insufficient_balance'; + + if (reason.includes("insufficient funds") || reason.includes("balance")) { + return "insufficient_balance"; } - - if (reason.includes('nonce') || reason.includes('replacement')) { - return 'nonce_conflict'; + + if (reason.includes("nonce") || reason.includes("replacement")) { + return "nonce_conflict"; } - - if (reason.includes('slippage') || reason.includes('too much requested')) { - return 'slippage_exceeded'; + + if ( + reason.includes("slippage") || + reason.includes("too much requested") + ) { + return "slippage_exceeded"; } - - if (reason.includes('revert') || reason.includes('require')) { - return 'contract_revert'; + + if (reason.includes("revert") || reason.includes("require")) { + return "contract_revert"; } } // Check nonce conflicts if (metadata?.nonce !== undefined) { const walletTxs = this.walletFailures.get(transactionData.wallet!) || []; - const hasSameNonce = walletTxs.some(tx => - tx.metadata.nonce === metadata.nonce && - tx.timestamp > new Date(Date.now() - 300000).toISOString() // Last 5 minutes + const hasSameNonce = walletTxs.some( + (tx) => + tx.metadata.nonce === metadata.nonce && + tx.timestamp > new Date(Date.now() - 300000).toISOString(), // Last 5 minutes ); - + if (hasSameNonce) { - return 'nonce_conflict'; + return "nonce_conflict"; } } - return 'unknown'; + return "unknown"; } /** * Analyze root cause with detailed evidence */ private async analyzeRootCause( - transactionData: Partial, - category: FailureCategory + transactionData: Partial, + category: FailureCategory, ): Promise { const evidence: string[] = []; const patterns: any = {}; switch (category) { - case 'underpriced_gas': + case "underpriced_gas": evidence.push(`Gas price: ${transactionData.gasPrice} wei`); evidence.push(`Chain ID: ${transactionData.chainId}`); patterns.pricing = { gasPrice: transactionData.gasPrice!, - networkGasPrice: await this.getNetworkGasPrice(transactionData.chainId!), - deviation: await this.calculateGasPriceDeviation(transactionData.gasPrice!, transactionData.chainId!) + networkGasPrice: await this.getNetworkGasPrice( + transactionData.chainId!, + ), + deviation: await this.calculateGasPriceDeviation( + transactionData.gasPrice!, + transactionData.chainId!, + ), }; break; - case 'out_of_gas': + case "out_of_gas": evidence.push(`Gas used: ${transactionData.gasUsed}`); evidence.push(`Gas limit: ${transactionData.metadata?.gasLimit}`); if (transactionData.gasUsed && transactionData.metadata?.gasLimit) { - const utilization = (parseInt(transactionData.gasUsed) / parseInt(transactionData.metadata.gasLimit)) * 100; + const utilization = + (parseInt(transactionData.gasUsed) / + parseInt(transactionData.metadata.gasLimit)) * + 100; evidence.push(`Utilization: ${utilization.toFixed(2)}%`); patterns.gasUsage = { used: transactionData.gasUsed, limit: transactionData.metadata.gasLimit, - utilization + utilization, }; } break; - case 'slippage_exceeded': + case "slippage_exceeded": evidence.push(`Revert reason: ${transactionData.revertReason}`); - evidence.push('DEX transaction detected'); + evidence.push("DEX transaction detected"); break; - case 'nonce_conflict': + case "nonce_conflict": evidence.push(`Nonce: ${transactionData.metadata?.nonce}`); - evidence.push('Duplicate nonce detected in recent transactions'); + evidence.push("Duplicate nonce detected in recent transactions"); break; - case 'insufficient_balance': + case "insufficient_balance": evidence.push(`Value: ${transactionData.metadata?.value}`); evidence.push(`Gas cost: ${transactionData.effectiveFee}`); evidence.push(`Revert reason: ${transactionData.revertReason}`); @@ -170,33 +194,41 @@ export class FailedTransactionService { category, confidence: this.calculateConfidence(category, evidence), evidence, - patterns + patterns, }; } /** * Calculate cost metrics for a wallet */ - async calculateCostMetrics(wallet: string, chainIds?: number[]): Promise { + async calculateCostMetrics( + wallet: string, + chainIds?: number[], + ): Promise { const failures = await this.getWalletFailures(wallet, chainIds); - + let totalGasWasted = BigInt(0); const wasteByCategory: Record = {} as any; const wasteByChain: Record = {} as any; // Initialize category counters const categories: FailureCategory[] = [ - 'underpriced_gas', 'out_of_gas', 'contract_revert', - 'slippage_exceeded', 'nonce_conflict', 'insufficient_balance', - 'network_error', 'unknown' + "underpriced_gas", + "out_of_gas", + "contract_revert", + "slippage_exceeded", + "nonce_conflict", + "insufficient_balance", + "network_error", + "unknown", ]; - - categories.forEach(cat => { - wasteByCategory[cat] = '0'; + + categories.forEach((cat) => { + wasteByCategory[cat] = "0"; }); // Calculate totals - failures.forEach(tx => { + failures.forEach((tx) => { const waste = BigInt(tx.effectiveFee); totalGasWasted += waste; @@ -207,7 +239,7 @@ export class FailedTransactionService { // Add to chain total if (!wasteByChain[tx.chainId]) { - wasteByChain[tx.chainId] = '0'; + wasteByChain[tx.chainId] = "0"; } wasteByChain[tx.chainId] = ( BigInt(wasteByChain[tx.chainId]) + waste @@ -219,10 +251,13 @@ export class FailedTransactionService { const dailyFailures = new Map(); failures - .filter(tx => new Date(tx.timestamp) >= thirtyDaysAgo) - .forEach(tx => { - const date = tx.timestamp.split('T')[0]; - const current = dailyFailures.get(date) || { waste: BigInt(0), count: 0 }; + .filter((tx) => new Date(tx.timestamp) >= thirtyDaysAgo) + .forEach((tx) => { + const date = tx.timestamp.split("T")[0]; + const current = dailyFailures.get(date) || { + waste: BigInt(0), + count: 0, + }; current.waste += BigInt(tx.effectiveFee); current.count += 1; dailyFailures.set(date, current); @@ -232,19 +267,20 @@ export class FailedTransactionService { .map(([date, data]) => ({ date, waste: data.waste.toString(), - failures: data.count + failures: data.count, })) .sort((a, b) => a.date.localeCompare(b.date)); return { totalGasWasted: totalGasWasted.toString(), totalGasWastedUSD: await this.convertToUSD(totalGasWasted.toString()), - averageWastePerFailure: failures.length > 0 - ? (totalGasWasted / BigInt(failures.length)).toString() - : '0', + averageWastePerFailure: + failures.length > 0 + ? (totalGasWasted / BigInt(failures.length)).toString() + : "0", wasteByCategory, wasteByChain, - historicalTrend + historicalTrend, }; } @@ -255,13 +291,18 @@ export class FailedTransactionService { return `ft_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } - private calculateEffectiveFee(transactionData: Partial): string { - const gasUsed = BigInt(transactionData.gasUsed || '0'); - const gasPrice = BigInt(transactionData.gasPrice || '0'); + private calculateEffectiveFee( + transactionData: Partial, + ): string { + const gasUsed = BigInt(transactionData.gasUsed || "0"); + const gasPrice = BigInt(transactionData.gasPrice || "0"); return (gasUsed * gasPrice).toString(); } - private async isUnderpriced(gasPrice: string, chainId: number): Promise { + private async isUnderpriced( + gasPrice: string, + chainId: number, + ): Promise { const networkGasPrice = await this.getNetworkGasPrice(chainId); const deviation = await this.calculateGasPriceDeviation(gasPrice, chainId); return deviation < -20; // More than 20% below network gas price @@ -270,33 +311,39 @@ export class FailedTransactionService { private async getNetworkGasPrice(chainId: number): Promise { // Mock implementation - in real scenario, this would call RPC const mockPrices: Record = { - 1: '20000000000', // Ethereum mainnet - 137: '30000000000', // Polygon - 56: '5000000000', // BSC - 42161: '100000000', // Arbitrum - 10: '100000000' // Optimism + 1: "20000000000", // Ethereum mainnet + 137: "30000000000", // Polygon + 56: "5000000000", // BSC + 42161: "100000000", // Arbitrum + 10: "100000000", // Optimism }; - return mockPrices[chainId] || '20000000000'; + return mockPrices[chainId] || "20000000000"; } - private async calculateGasPriceDeviation(gasPrice: string, chainId: number): Promise { + private async calculateGasPriceDeviation( + gasPrice: string, + chainId: number, + ): Promise { const networkGasPrice = await this.getNetworkGasPrice(chainId); const price = BigInt(gasPrice); const network = BigInt(networkGasPrice); return Number(((price - network) * BigInt(100)) / network); } - private calculateConfidence(category: FailureCategory, evidence: string[]): number { + private calculateConfidence( + category: FailureCategory, + evidence: string[], + ): number { // Base confidence by category const baseConfidence: Record = { - 'underpriced_gas': 0.9, - 'out_of_gas': 0.95, - 'contract_revert': 0.8, - 'slippage_exceeded': 0.85, - 'nonce_conflict': 0.9, - 'insufficient_balance': 0.95, - 'network_error': 0.7, - 'unknown': 0.3 + underpriced_gas: 0.9, + out_of_gas: 0.95, + contract_revert: 0.8, + slippage_exceeded: 0.85, + nonce_conflict: 0.9, + insufficient_balance: 0.95, + network_error: 0.7, + unknown: 0.3, }; // Adjust based on evidence strength diff --git a/apps/api/src/services/mitigation.service.ts b/apps/api/src/services/mitigation.service.ts index 1ddd137..c81c39e 100644 --- a/apps/api/src/services/mitigation.service.ts +++ b/apps/api/src/services/mitigation.service.ts @@ -1,32 +1,43 @@ -import { Injectable } from '@nestjs/common'; -import { - FailureCategory, - MitigationRecommendation, +import { Injectable } from "@nestjs/common"; +import { + FailureCategory, + MitigationRecommendation, FailedTransaction, - FailureAnalysis -} from '../schemas/failed-transaction.schema'; + FailureAnalysis, +} from "../schemas/failed-transaction.schema"; @Injectable() export class MitigationService { /** * Generate mitigation recommendations based on failure analysis */ - async generateRecommendations(analysis: FailureAnalysis): Promise { + async generateRecommendations( + analysis: FailureAnalysis, + ): Promise { const recommendations: MitigationRecommendation[] = []; - + // Generate recommendations based on top failure category const topCategory = analysis.topFailureCategory; - recommendations.push(...await this.getCategorySpecificRecommendations(topCategory, analysis)); - + recommendations.push( + ...(await this.getCategorySpecificRecommendations(topCategory, analysis)), + ); + // Generate recommendations based on patterns - recommendations.push(...await this.getPatternBasedRecommendations(analysis)); - + recommendations.push( + ...(await this.getPatternBasedRecommendations(analysis)), + ); + // Generate chain-specific recommendations - recommendations.push(...await this.getChainSpecificRecommendations(analysis)); - + recommendations.push( + ...(await this.getChainSpecificRecommendations(analysis)), + ); + // Sort by priority and return top recommendations return recommendations - .sort((a, b) => this.getPriorityScore(b.priority) - this.getPriorityScore(a.priority)) + .sort( + (a, b) => + this.getPriorityScore(b.priority) - this.getPriorityScore(a.priority), + ) .slice(0, 5); // Return top 5 recommendations } @@ -34,112 +45,118 @@ export class MitigationService { * Get category-specific mitigation recommendations */ private async getCategorySpecificRecommendations( - category: FailureCategory, - analysis: FailureAnalysis + category: FailureCategory, + analysis: FailureAnalysis, ): Promise { const recommendations: MitigationRecommendation[] = []; switch (category) { - case 'underpriced_gas': + case "underpriced_gas": recommendations.push({ - id: 'increase_gas_price', - category: 'underpriced_gas', - priority: 'high', - title: 'Increase Gas Price', - description: 'Your transactions are failing due to gas prices that are too low for network conditions.', - action: 'Increase priority fee by 25-50% during peak hours', - estimatedImpact: 'Reduces failure rate by 80-90%', + id: "increase_gas_price", + category: "underpriced_gas", + priority: "high", + title: "Increase Gas Price", + description: + "Your transactions are failing due to gas prices that are too low for network conditions.", + action: "Increase priority fee by 25-50% during peak hours", + estimatedImpact: "Reduces failure rate by 80-90%", parameters: { - priorityFeeIncrease: '30%', - congestionMultiplier: 1.5 - } + priorityFeeIncrease: "30%", + congestionMultiplier: 1.5, + }, }); recommendations.push({ - id: 'use_gas_tier', - category: 'underpriced_gas', - priority: 'medium', - title: 'Use Dynamic Gas Tiers', - description: 'Implement adaptive gas pricing based on network congestion.', - action: 'Use Medium/High gas tiers during peak hours, Low during off-peak', - estimatedImpact: 'Optimizes cost while maintaining reliability' + id: "use_gas_tier", + category: "underpriced_gas", + priority: "medium", + title: "Use Dynamic Gas Tiers", + description: + "Implement adaptive gas pricing based on network congestion.", + action: + "Use Medium/High gas tiers during peak hours, Low during off-peak", + estimatedImpact: "Optimizes cost while maintaining reliability", }); break; - case 'out_of_gas': + case "out_of_gas": recommendations.push({ - id: 'increase_gas_limit', - category: 'out_of_gas', - priority: 'high', - title: 'Increase Gas Limit', - description: 'Transactions are failing due to insufficient gas limits.', - action: 'Increase gas limit by 20-30% for complex operations', - estimatedImpact: 'Eliminates out-of-gas failures', + id: "increase_gas_limit", + category: "out_of_gas", + priority: "high", + title: "Increase Gas Limit", + description: + "Transactions are failing due to insufficient gas limits.", + action: "Increase gas limit by 20-30% for complex operations", + estimatedImpact: "Eliminates out-of-gas failures", parameters: { gasLimitMultiplier: 1.25, - minIncrease: '21000' - } + minIncrease: "21000", + }, }); recommendations.push({ - id: 'gas_limit_optimization', - category: 'out_of_gas', - priority: 'medium', - title: 'Optimize Gas Limit Estimation', - description: 'Implement better gas estimation algorithms.', - action: 'Use historical data to predict gas requirements more accurately', - estimatedImpact: 'Reduces wasted gas by 15-25%' + id: "gas_limit_optimization", + category: "out_of_gas", + priority: "medium", + title: "Optimize Gas Limit Estimation", + description: "Implement better gas estimation algorithms.", + action: + "Use historical data to predict gas requirements more accurately", + estimatedImpact: "Reduces wasted gas by 15-25%", }); break; - case 'slippage_exceeded': + case "slippage_exceeded": recommendations.push({ - id: 'adjust_slippage', - category: 'slippage_exceeded', - priority: 'high', - title: 'Adjust Slippage Tolerance', - description: 'DEX trades are failing due to price movement exceeding slippage tolerance.', - action: 'Increase slippage tolerance to 1-2% during high volatility', - estimatedImpact: 'Reduces DEX failure rate by 70%', + id: "adjust_slippage", + category: "slippage_exceeded", + priority: "high", + title: "Adjust Slippage Tolerance", + description: + "DEX trades are failing due to price movement exceeding slippage tolerance.", + action: "Increase slippage tolerance to 1-2% during high volatility", + estimatedImpact: "Reduces DEX failure rate by 70%", parameters: { - defaultSlippage: '1%', - highVolatilitySlippage: '2%' - } + defaultSlippage: "1%", + highVolatilitySlippage: "2%", + }, }); recommendations.push({ - id: 'timing_optimization', - category: 'slippage_exceeded', - priority: 'medium', - title: 'Optimize Trading Timing', - description: 'Avoid trading during high volatility periods.', - action: 'Use volatility indicators to time trades optimally', - estimatedImpact: 'Improves success rate and reduces slippage' + id: "timing_optimization", + category: "slippage_exceeded", + priority: "medium", + title: "Optimize Trading Timing", + description: "Avoid trading during high volatility periods.", + action: "Use volatility indicators to time trades optimally", + estimatedImpact: "Improves success rate and reduces slippage", }); break; - case 'nonce_conflict': + case "nonce_conflict": recommendations.push({ - id: 'nonce_management', - category: 'nonce_conflict', - priority: 'high', - title: 'Implement Nonce Management', - description: 'Transactions are failing due to nonce conflicts.', - action: 'Replace stuck transactions with higher gas prices', - estimatedImpact: 'Resolves stuck transactions immediately', + id: "nonce_management", + category: "nonce_conflict", + priority: "high", + title: "Implement Nonce Management", + description: "Transactions are failing due to nonce conflicts.", + action: "Replace stuck transactions with higher gas prices", + estimatedImpact: "Resolves stuck transactions immediately", parameters: { replacementMultiplier: 1.1, - maxRetries: 3 - } + maxRetries: 3, + }, }); break; - case 'insufficient_balance': + case "insufficient_balance": recommendations.push({ - id: 'balance_check', - category: 'insufficient_balance', - priority: 'high', - title: 'Pre-transaction Balance Check', - description: 'Transactions are failing due to insufficient balance.', - action: 'Verify balance + gas costs before submitting transactions', - estimatedImpact: 'Eliminates balance-related failures' + id: "balance_check", + category: "insufficient_balance", + priority: "high", + title: "Pre-transaction Balance Check", + description: "Transactions are failing due to insufficient balance.", + action: "Verify balance + gas costs before submitting transactions", + estimatedImpact: "Eliminates balance-related failures", }); break; } @@ -150,19 +167,22 @@ export class MitigationService { /** * Get pattern-based recommendations from failure patterns */ - private async getPatternBasedRecommendations(analysis: FailureAnalysis): Promise { + private async getPatternBasedRecommendations( + analysis: FailureAnalysis, + ): Promise { const recommendations: MitigationRecommendation[] = []; // Check for repeated failures if (analysis.totalFailures > 10) { recommendations.push({ - id: 'batch_optimization', - category: 'unknown', - priority: 'medium', - title: 'Implement Batch Processing', - description: 'High failure rate suggests systematic issues with transaction handling.', - action: 'Group similar transactions and optimize batch processing', - estimatedImpact: 'Reduces overall failure rate by 40%' + id: "batch_optimization", + category: "unknown", + priority: "medium", + title: "Implement Batch Processing", + description: + "High failure rate suggests systematic issues with transaction handling.", + action: "Group similar transactions and optimize batch processing", + estimatedImpact: "Reduces overall failure rate by 40%", }); } @@ -170,13 +190,14 @@ export class MitigationService { const chainIds = Object.keys(analysis.chainBreakdown).map(Number); if (chainIds.length > 3) { recommendations.push({ - id: 'chain_optimization', - category: 'unknown', - priority: 'low', - title: 'Optimize Multi-chain Strategy', - description: 'Failures across multiple chains suggest suboptimal chain selection.', - action: 'Use chain-specific optimization strategies', - estimatedImpact: 'Improves cross-chain success rates' + id: "chain_optimization", + category: "unknown", + priority: "low", + title: "Optimize Multi-chain Strategy", + description: + "Failures across multiple chains suggest suboptimal chain selection.", + action: "Use chain-specific optimization strategies", + estimatedImpact: "Improves cross-chain success rates", }); } @@ -186,23 +207,26 @@ export class MitigationService { /** * Get chain-specific recommendations */ - private async getChainSpecificRecommendations(analysis: FailureAnalysis): Promise { + private async getChainSpecificRecommendations( + analysis: FailureAnalysis, + ): Promise { const recommendations: MitigationRecommendation[] = []; for (const [chainId, stats] of Object.entries(analysis.chainBreakdown)) { const chainNum = parseInt(chainId); - + if (stats.failures > 5) { switch (chainNum) { case 1: // Ethereum Mainnet recommendations.push({ id: `ethereum_optimization_${chainId}`, category: stats.mostCommonCategory, - priority: 'medium', - title: 'Ethereum Mainnet Optimization', - description: 'Optimize transactions for Ethereum mainnet conditions.', - action: 'Use EIP-1559 with dynamic base fee tracking', - estimatedImpact: 'Reduces mainnet failure rate by 30%' + priority: "medium", + title: "Ethereum Mainnet Optimization", + description: + "Optimize transactions for Ethereum mainnet conditions.", + action: "Use EIP-1559 with dynamic base fee tracking", + estimatedImpact: "Reduces mainnet failure rate by 30%", }); break; @@ -210,11 +234,11 @@ export class MitigationService { recommendations.push({ id: `polygon_optimization_${chainId}`, category: stats.mostCommonCategory, - priority: 'medium', - title: 'Polygon Network Optimization', - description: 'Optimize for Polygon\'s faster block times.', - action: 'Reduce confirmation time expectations', - estimatedImpact: 'Improves Polygon success rates' + priority: "medium", + title: "Polygon Network Optimization", + description: "Optimize for Polygon's faster block times.", + action: "Reduce confirmation time expectations", + estimatedImpact: "Improves Polygon success rates", }); break; @@ -222,11 +246,11 @@ export class MitigationService { recommendations.push({ id: `arbitrum_optimization_${chainId}`, category: stats.mostCommonCategory, - priority: 'medium', - title: 'Arbitrum Optimization', - description: 'Optimize for Arbitrum\'s L2 characteristics.', - action: 'Account for Arbitrum\'s gas pricing model', - estimatedImpact: 'Optimizes L2 transaction costs' + priority: "medium", + title: "Arbitrum Optimization", + description: "Optimize for Arbitrum's L2 characteristics.", + action: "Account for Arbitrum's gas pricing model", + estimatedImpact: "Optimizes L2 transaction costs", }); break; } @@ -239,52 +263,56 @@ export class MitigationService { /** * Get real-time mitigation for a specific failed transaction */ - async getImmediateMitigation(transaction: FailedTransaction): Promise { + async getImmediateMitigation( + transaction: FailedTransaction, + ): Promise { const recommendations: MitigationRecommendation[] = []; switch (transaction.failureCategory) { - case 'underpriced_gas': - const suggestedGasPrice = await this.calculateOptimalGasPrice(transaction.chainId); + case "underpriced_gas": + const suggestedGasPrice = await this.calculateOptimalGasPrice( + transaction.chainId, + ); recommendations.push({ - id: 'immediate_gas_increase', - category: 'underpriced_gas', - priority: 'high', - title: 'Immediate Gas Price Increase', - description: 'Replace transaction with higher gas price.', + id: "immediate_gas_increase", + category: "underpriced_gas", + priority: "high", + title: "Immediate Gas Price Increase", + description: "Replace transaction with higher gas price.", action: `Set gas price to ${suggestedGasPrice} wei`, - estimatedImpact: 'Immediate transaction confirmation', + estimatedImpact: "Immediate transaction confirmation", parameters: { suggestedGasPrice, - replacementMultiplier: 1.1 - } + replacementMultiplier: 1.1, + }, }); break; - case 'out_of_gas': + case "out_of_gas": const suggestedGasLimit = this.calculateOptimalGasLimit(transaction); recommendations.push({ - id: 'immediate_gas_limit_increase', - category: 'out_of_gas', - priority: 'high', - title: 'Immediate Gas Limit Increase', - description: 'Resubmit with higher gas limit.', + id: "immediate_gas_limit_increase", + category: "out_of_gas", + priority: "high", + title: "Immediate Gas Limit Increase", + description: "Resubmit with higher gas limit.", action: `Set gas limit to ${suggestedGasLimit}`, - estimatedImpact: 'Transaction will execute successfully', + estimatedImpact: "Transaction will execute successfully", parameters: { - suggestedGasLimit - } + suggestedGasLimit, + }, }); break; - case 'nonce_conflict': + case "nonce_conflict": recommendations.push({ - id: 'immediate_nonce_fix', - category: 'nonce_conflict', - priority: 'high', - title: 'Replace Stuck Transaction', - description: 'Cancel and replace the stuck transaction.', - action: 'Send 0 ETH transaction with same nonce and higher gas', - estimatedImpact: 'Clears nonce conflict immediately' + id: "immediate_nonce_fix", + category: "nonce_conflict", + priority: "high", + title: "Replace Stuck Transaction", + description: "Cancel and replace the stuck transaction.", + action: "Send 0 ETH transaction with same nonce and higher gas", + estimatedImpact: "Clears nonce conflict immediately", }); break; } @@ -303,35 +331,38 @@ export class MitigationService { private async calculateOptimalGasPrice(chainId: number): Promise { // Mock implementation - in real scenario, this would call gas price oracle const baseGasPrices: Record = { - 1: '30000000000', // Ethereum mainnet - 137: '50000000000', // Polygon - 56: '10000000000', // BSC - 42161: '200000000', // Arbitrum - 10: '200000000' // Optimism + 1: "30000000000", // Ethereum mainnet + 137: "50000000000", // Polygon + 56: "10000000000", // BSC + 42161: "200000000", // Arbitrum + 10: "200000000", // Optimism }; - - const basePrice = baseGasPrices[chainId] || '20000000000'; + + const basePrice = baseGasPrices[chainId] || "20000000000"; const multiplier = 1.2; // 20% above base for reliability - - return (BigInt(basePrice) * BigInt(Math.floor(multiplier * 100)) / BigInt(100)).toString(); + + return ( + (BigInt(basePrice) * BigInt(Math.floor(multiplier * 100))) / + BigInt(100) + ).toString(); } private calculateOptimalGasLimit(transaction: FailedTransaction): string { const currentLimit = BigInt(transaction.metadata.gasLimit); const used = BigInt(transaction.gasUsed); - + // Calculate utilization const utilization = Number((used * BigInt(100)) / currentLimit); - + if (utilization >= 99.5) { // Out of gas - increase by 30% - return (currentLimit * BigInt(130) / BigInt(100)).toString(); + return ((currentLimit * BigInt(130)) / BigInt(100)).toString(); } else if (utilization >= 95) { // Near limit - increase by 20% - return (currentLimit * BigInt(120) / BigInt(100)).toString(); + return ((currentLimit * BigInt(120)) / BigInt(100)).toString(); } else { // Moderate utilization - increase by 10% - return (currentLimit * BigInt(110) / BigInt(100)).toString(); + return ((currentLimit * BigInt(110)) / BigInt(100)).toString(); } } } diff --git a/apps/api/src/services/rpc-provider-manager.spec.ts b/apps/api/src/services/rpc-provider-manager.spec.ts index ff8c9c6..6285885 100644 --- a/apps/api/src/services/rpc-provider-manager.spec.ts +++ b/apps/api/src/services/rpc-provider-manager.spec.ts @@ -1,40 +1,43 @@ -import { RpcProviderManager } from './rpc-provider-manager'; -import { providers } from 'ethers'; +import { RpcProviderManager } from "./rpc-provider-manager"; +import { providers } from "ethers"; -describe('RpcProviderManager', () => { +describe("RpcProviderManager", () => { const configs = [ - { url: 'https://mock1', priority: 1 }, - { url: 'https://mock2', priority: 2 } + { url: "https://mock1", priority: 1 }, + { url: "https://mock2", priority: 2 }, ]; let manager: RpcProviderManager; beforeEach(() => { - manager = new RpcProviderManager('TestChain', configs); + manager = new RpcProviderManager("TestChain", configs); }); - it('should return the current provider', () => { + it("should return the current provider", () => { const provider = manager.getCurrentProvider(); expect(provider).toBeInstanceOf(providers.JsonRpcProvider); - expect(provider.connection.url).toBe('https://mock1'); + expect(provider.connection.url).toBe("https://mock1"); }); - it('should failover to next healthy provider', () => { + it("should failover to next healthy provider", () => { // Simulate unhealthy first provider const health = manager.getHealth(); health[0].healthy = false; manager.failover(); const provider = manager.getCurrentProvider(); - expect(provider.connection.url).toBe('https://mock2'); + expect(provider.connection.url).toBe("https://mock2"); }); - it('should cycle through providers on repeated failover', () => { + it("should cycle through providers on repeated failover", () => { manager.failover(); - expect(manager.getCurrentProvider().connection.url).toBe('https://mock2'); + expect(manager.getCurrentProvider().connection.url).toBe("https://mock2"); manager.failover(); - expect(manager.getCurrentProvider().connection.url).toBe('https://mock1'); + expect(manager.getCurrentProvider().connection.url).toBe("https://mock1"); }); - it('should return all provider URLs', () => { - expect(manager.getProviderUrls()).toEqual(['https://mock1', 'https://mock2']); + it("should return all provider URLs", () => { + expect(manager.getProviderUrls()).toEqual([ + "https://mock1", + "https://mock2", + ]); }); }); diff --git a/apps/api/src/services/rpc-provider-manager.ts b/apps/api/src/services/rpc-provider-manager.ts index 768ffe1..53659bf 100644 --- a/apps/api/src/services/rpc-provider-manager.ts +++ b/apps/api/src/services/rpc-provider-manager.ts @@ -1,4 +1,4 @@ -import { providers } from 'ethers'; +import { providers } from "ethers"; export interface RpcProviderConfig { url: string; @@ -25,7 +25,7 @@ export class RpcProviderManager { constructor(chainName: string, configs: RpcProviderConfig[]) { this.chainName = chainName; this.providers = configs.sort((a, b) => a.priority - b.priority); - this.providers.forEach(cfg => { + this.providers.forEach((cfg) => { this.health.set(cfg.url, { url: cfg.url, healthy: true, @@ -50,7 +50,9 @@ export class RpcProviderManager { try { await Promise.race([ provider.getBlockNumber(), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutMs), + ), ]); healthy = true; } catch (e) { @@ -84,6 +86,6 @@ export class RpcProviderManager { } public getProviderUrls(): string[] { - return this.providers.map(p => p.url); + return this.providers.map((p) => p.url); } } diff --git a/apps/api/src/services/transaction-analysis.service.ts b/apps/api/src/services/transaction-analysis.service.ts index 7cf0bfb..3e7023e 100644 --- a/apps/api/src/services/transaction-analysis.service.ts +++ b/apps/api/src/services/transaction-analysis.service.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@nestjs/common'; -import { FailedTransactionService } from './failed-transaction.service'; -import { MitigationService } from './mitigation.service'; -import { - TransactionAnalysisRequest, +import { Injectable } from "@nestjs/common"; +import { FailedTransactionService } from "./failed-transaction.service"; +import { MitigationService } from "./mitigation.service"; +import { + TransactionAnalysisRequest, TransactionAnalysisResponse, FailureAnalysis, ChainFailureStats, FailedTransaction, - FailureCategory -} from '../schemas/failed-transaction.schema'; + FailureCategory, +} from "../schemas/failed-transaction.schema"; @Injectable() export class TransactionAnalysisService { @@ -20,26 +20,40 @@ export class TransactionAnalysisService { /** * Analyze failed transactions for a wallet */ - async analyzeWalletFailures(request: TransactionAnalysisRequest): Promise { - const { wallet, chainIds, timeframe, includeRecommendations = true } = request; + async analyzeWalletFailures( + request: TransactionAnalysisRequest, + ): Promise { + const { + wallet, + chainIds, + timeframe, + includeRecommendations = true, + } = request; // Get failed transactions - const failures = await this.failedTransactionService.getWalletFailures(wallet, chainIds); - + const failures = await this.failedTransactionService.getWalletFailures( + wallet, + chainIds, + ); + // Filter by timeframe if specified const filteredFailures = this.filterByTimeframe(failures, timeframe); - + // Calculate cost metrics - const costMetrics = await this.failedTransactionService.calculateCostMetrics(wallet, chainIds); - + const costMetrics = + await this.failedTransactionService.calculateCostMetrics( + wallet, + chainIds, + ); + // Analyze failure categories const failureCategories = this.categorizeFailures(filteredFailures); - + // Calculate chain breakdown const chainBreakdown = this.calculateChainBreakdown(filteredFailures); - + // Generate recommendations - const recommendations = includeRecommendations + const recommendations = includeRecommendations ? await this.mitigationService.generateRecommendations({ wallet, totalFailures: filteredFailures.length, @@ -49,10 +63,11 @@ export class TransactionAnalysisService { topFailureCategory: this.getTopFailureCategory(failureCategories), recommendations: [], // Will be populated timeframe: { - start: timeframe?.start || this.getDefaultStartDate(filteredFailures), - end: timeframe?.end || new Date().toISOString() + start: + timeframe?.start || this.getDefaultStartDate(filteredFailures), + end: timeframe?.end || new Date().toISOString(), }, - chainBreakdown + chainBreakdown, }) : []; @@ -66,40 +81,48 @@ export class TransactionAnalysisService { recommendations, timeframe: { start: timeframe?.start || this.getDefaultStartDate(filteredFailures), - end: timeframe?.end || new Date().toISOString() + end: timeframe?.end || new Date().toISOString(), }, - chainBreakdown + chainBreakdown, }; return { wallet, analysis, processedAt: new Date().toISOString(), - requestId: this.generateRequestId() + requestId: this.generateRequestId(), }; } /** * Get immediate mitigation for a recent failure */ - async getImmediateMitigation(wallet: string, transactionHash?: string): Promise { - const failures = await this.failedTransactionService.getWalletFailures(wallet); - + async getImmediateMitigation( + wallet: string, + transactionHash?: string, + ): Promise { + const failures = + await this.failedTransactionService.getWalletFailures(wallet); + // Get the most recent failure or specific transaction const targetFailure = transactionHash - ? failures.find(f => f.hash === transactionHash) - : failures.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]; + ? failures.find((f) => f.hash === transactionHash) + : failures.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + )[0]; if (!targetFailure) { - throw new Error('No failed transaction found'); + throw new Error("No failed transaction found"); } - const recommendations = await this.mitigationService.getImmediateMitigation(targetFailure); + const recommendations = + await this.mitigationService.getImmediateMitigation(targetFailure); return { transaction: targetFailure, recommendations, - processedAt: new Date().toISOString() + processedAt: new Date().toISOString(), }; } @@ -107,15 +130,25 @@ export class TransactionAnalysisService { * Get wallet statistics summary */ async getWalletSummary(wallet: string, chainIds?: number[]): Promise { - const failures = await this.failedTransactionService.getWalletFailures(wallet, chainIds); - const costMetrics = await this.failedTransactionService.calculateCostMetrics(wallet, chainIds); - - const last30Days = failures.filter(f => - new Date(f.timestamp) >= new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const failures = await this.failedTransactionService.getWalletFailures( + wallet, + chainIds, ); - - const last7Days = failures.filter(f => - new Date(f.timestamp) >= new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + const costMetrics = + await this.failedTransactionService.calculateCostMetrics( + wallet, + chainIds, + ); + + const last30Days = failures.filter( + (f) => + new Date(f.timestamp) >= + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + ); + + const last7Days = failures.filter( + (f) => + new Date(f.timestamp) >= new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), ); return { @@ -128,57 +161,73 @@ export class TransactionAnalysisService { last7DaysFailures: last7Days.length, averageFailuresPerDay: (last30Days.length / 30).toFixed(2), mostActiveChain: this.getMostActiveChain(failures), - topFailureCategory: this.getTopFailureCategory(this.categorizeFailures(failures)) + topFailureCategory: this.getTopFailureCategory( + this.categorizeFailures(failures), + ), }, - processedAt: new Date().toISOString() + processedAt: new Date().toISOString(), }; } /** * Process a failed transaction event */ - async processFailedTransaction(transactionData: Partial): Promise { - return await this.failedTransactionService.trackFailedTransaction(transactionData); + async processFailedTransaction( + transactionData: Partial, + ): Promise { + return await this.failedTransactionService.trackFailedTransaction( + transactionData, + ); } /** * Helper methods */ - private filterByTimeframe(failures: FailedTransaction[], timeframe?: { start?: string; end?: string }): FailedTransaction[] { + private filterByTimeframe( + failures: FailedTransaction[], + timeframe?: { start?: string; end?: string }, + ): FailedTransaction[] { if (!timeframe) return failures; - return failures.filter(tx => { + return failures.filter((tx) => { const txDate = new Date(tx.timestamp); - const startDate = timeframe.start ? new Date(timeframe.start) : new Date(0); + const startDate = timeframe.start + ? new Date(timeframe.start) + : new Date(0); const endDate = timeframe.end ? new Date(timeframe.end) : new Date(); - + return txDate >= startDate && txDate <= endDate; }); } - private categorizeFailures(failures: FailedTransaction[]): Record { + private categorizeFailures( + failures: FailedTransaction[], + ): Record { const categories: Record = {}; - - failures.forEach(tx => { - categories[tx.failureCategory] = (categories[tx.failureCategory] || 0) + 1; + + failures.forEach((tx) => { + categories[tx.failureCategory] = + (categories[tx.failureCategory] || 0) + 1; }); return categories; } - private calculateChainBreakdown(failures: FailedTransaction[]): Record { + private calculateChainBreakdown( + failures: FailedTransaction[], + ): Record { const chainStats: Record = {}; - - failures.forEach(tx => { + + failures.forEach((tx) => { if (!chainStats[tx.chainId]) { chainStats[tx.chainId] = { chainId: tx.chainId, failures: 0, - gasWasted: '0', - mostCommonCategory: 'unknown' + gasWasted: "0", + mostCommonCategory: "unknown", }; } - + const stats = chainStats[tx.chainId]; stats.failures += 1; stats.gasWasted = ( @@ -187,19 +236,24 @@ export class TransactionAnalysisService { }); // Calculate most common category for each chain - Object.keys(chainStats).forEach(chainId => { - const chainFailures = failures.filter(tx => tx.chainId === parseInt(chainId)); + Object.keys(chainStats).forEach((chainId) => { + const chainFailures = failures.filter( + (tx) => tx.chainId === parseInt(chainId), + ); const categories = this.categorizeFailures(chainFailures); - chainStats[parseInt(chainId)].mostCommonCategory = this.getTopFailureCategory(categories); + chainStats[parseInt(chainId)].mostCommonCategory = + this.getTopFailureCategory(categories); }); return chainStats; } - private getTopFailureCategory(categories: Record): FailureCategory { + private getTopFailureCategory( + categories: Record, + ): FailureCategory { let maxCount = 0; - let topCategory: FailureCategory = 'unknown'; - + let topCategory: FailureCategory = "unknown"; + Object.entries(categories).forEach(([category, count]) => { if (count > maxCount) { maxCount = count; @@ -212,14 +266,14 @@ export class TransactionAnalysisService { private getMostActiveChain(failures: FailedTransaction[]): number { const chainCounts: Record = {}; - - failures.forEach(tx => { + + failures.forEach((tx) => { chainCounts[tx.chainId] = (chainCounts[tx.chainId] || 0) + 1; }); let maxCount = 0; let topChain = 1; - + Object.entries(chainCounts).forEach(([chainId, count]) => { if (count > maxCount) { maxCount = count; @@ -234,11 +288,13 @@ export class TransactionAnalysisService { if (failures.length === 0) { return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days ago } - - const oldestTx = failures.reduce((oldest, current) => - new Date(current.timestamp) < new Date(oldest.timestamp) ? current : oldest + + const oldestTx = failures.reduce((oldest, current) => + new Date(current.timestamp) < new Date(oldest.timestamp) + ? current + : oldest, ); - + return oldestTx.timestamp; } diff --git a/apps/api/src/validation/analysis.validator.ts b/apps/api/src/validation/analysis.validator.ts index 3b70670..98dc9b7 100644 --- a/apps/api/src/validation/analysis.validator.ts +++ b/apps/api/src/validation/analysis.validator.ts @@ -1,15 +1,32 @@ -import { Request, Response, NextFunction } from 'express'; -import { CodebaseSubmissionRequest, ValidationError } from '../schemas/analysis.schema'; -import { BaseValidator } from './base.validator'; +import { Request, Response, NextFunction } from "express"; +import { + CodebaseSubmissionRequest, + ValidationError, +} from "../schemas/analysis.schema"; +import { BaseValidator } from "./base.validator"; export class AnalysisValidator extends BaseValidator { - private static readonly SUPPORTED_LANGUAGES = ['rust', 'typescript', 'javascript', 'solidity', 'soroban']; - private static readonly SUPPORTED_FRAMEWORKS = ['soroban', 'solidity', 'general']; + private static readonly SUPPORTED_LANGUAGES = [ + "rust", + "typescript", + "javascript", + "solidity", + "soroban", + ]; + private static readonly SUPPORTED_FRAMEWORKS = [ + "soroban", + "solidity", + "general", + ]; private static readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB private static readonly MAX_FILES = 100; private static readonly MAX_CONTENT_LENGTH = 50 * 1024 * 1024; // 50MB - static validateSubmission(req: Request, res: Response, next: NextFunction): void { + static validateSubmission( + req: Request, + res: Response, + next: NextFunction, + ): void { const body = req.body as CodebaseSubmissionRequest; const errors: ValidationError[] = []; @@ -17,82 +34,92 @@ export class AnalysisValidator extends BaseValidator { // Validate project information if (!body.project) { errors.push({ - field: 'project', - message: 'Project information is required', - constraint: 'required' + field: "project", + message: "Project information is required", + constraint: "required", }); } else { if (!body.project.name || body.project.name.trim().length === 0) { errors.push({ - field: 'project.name', - message: 'Project name is required', + field: "project.name", + message: "Project name is required", value: body.project.name, - constraint: 'required' + constraint: "required", }); } if (body.project.name && body.project.name.length > 100) { errors.push({ - field: 'project.name', - message: 'Project name must be less than 100 characters', + field: "project.name", + message: "Project name must be less than 100 characters", value: body.project.name, - constraint: 'maxLength' + constraint: "maxLength", }); } - if (body.project.repositoryUrl && !this.isValidUrl(body.project.repositoryUrl)) { + if ( + body.project.repositoryUrl && + !this.isValidUrl(body.project.repositoryUrl) + ) { errors.push({ - field: 'project.repositoryUrl', - message: 'Invalid repository URL format', + field: "project.repositoryUrl", + message: "Invalid repository URL format", value: body.project.repositoryUrl, - constraint: 'url' + constraint: "url", }); } } // Validate files - if (!body.files || !Array.isArray(body.files) || body.files.length === 0) { + if ( + !body.files || + !Array.isArray(body.files) || + body.files.length === 0 + ) { errors.push({ - field: 'files', - message: 'At least one file must be submitted', - constraint: 'required' + field: "files", + message: "At least one file must be submitted", + constraint: "required", }); } else { if (body.files.length > this.MAX_FILES) { errors.push({ - field: 'files', + field: "files", message: `Maximum ${this.MAX_FILES} files allowed`, value: body.files.length, - constraint: 'maxFiles' + constraint: "maxFiles", }); } let totalSize = 0; body.files.forEach((file, index) => { const prefix = `files[${index}]`; - + if (!file.path || file.path.trim().length === 0) { errors.push({ field: `${prefix}.path`, - message: 'File path is required', - constraint: 'required' + message: "File path is required", + constraint: "required", }); } if (!file.content || file.content.trim().length === 0) { errors.push({ field: `${prefix}.content`, - message: 'File content is required', - constraint: 'required' + message: "File content is required", + constraint: "required", }); } - if (!file.language || !this.SUPPORTED_LANGUAGES.includes(file.language)) { + if ( + !file.language || + !this.SUPPORTED_LANGUAGES.includes(file.language) + ) { errors.push({ field: `${prefix}.language`, - message: `Language must be one of: ${this.SUPPORTED_LANGUAGES.join(', ')}`, + message: `Language must be one of: ${this.SUPPORTED_LANGUAGES.join(", ")}`, value: file.language, - constraint: 'enum' + constraint: "enum", }); } @@ -101,7 +128,7 @@ export class AnalysisValidator extends BaseValidator { field: `${prefix}.size`, message: `File size exceeds maximum of ${this.MAX_FILE_SIZE} bytes`, value: file.size, - constraint: 'maxSize' + constraint: "maxSize", }); } @@ -110,10 +137,10 @@ export class AnalysisValidator extends BaseValidator { if (totalSize > this.MAX_CONTENT_LENGTH) { errors.push({ - field: 'files', + field: "files", message: `Total content size exceeds maximum of ${this.MAX_CONTENT_LENGTH} bytes`, value: totalSize, - constraint: 'maxTotalSize' + constraint: "maxTotalSize", }); } } @@ -122,36 +149,41 @@ export class AnalysisValidator extends BaseValidator { if (body.options) { if (!this.SUPPORTED_FRAMEWORKS.includes(body.options.scanType)) { errors.push({ - field: 'options.scanType', + field: "options.scanType", message: `Scan type must be one of: security, performance, gas-optimization, full`, value: body.options.scanType, - constraint: 'enum' + constraint: "enum", }); } - if (!['low', 'medium', 'high', 'critical'].includes(body.options.severity)) { + if ( + !["low", "medium", "high", "critical"].includes(body.options.severity) + ) { errors.push({ - field: 'options.severity', + field: "options.severity", message: `Severity must be one of: low, medium, high, critical`, value: body.options.severity, - constraint: 'enum' + constraint: "enum", }); } } // Validate metadata if (body.metadata) { - if (body.metadata.framework && !this.SUPPORTED_FRAMEWORKS.includes(body.metadata.framework)) { + if ( + body.metadata.framework && + !this.SUPPORTED_FRAMEWORKS.includes(body.metadata.framework) + ) { errors.push({ - field: 'metadata.framework', - message: `Framework must be one of: ${this.SUPPORTED_FRAMEWORKS.join(', ')}`, + field: "metadata.framework", + message: `Framework must be one of: ${this.SUPPORTED_FRAMEWORKS.join(", ")}`, value: body.metadata.framework, - constraint: 'enum' + constraint: "enum", }); } // Soroban-specific validation - if (body.metadata.framework === 'soroban') { + if (body.metadata.framework === "soroban") { this.validateSorobanProject(body.files, body.metadata, errors); } } @@ -159,12 +191,12 @@ export class AnalysisValidator extends BaseValidator { if (errors.length > 0) { res.status(400).json({ error: { - code: 'VALIDATION_ERROR', - message: 'Request validation failed', + code: "VALIDATION_ERROR", + message: "Request validation failed", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' + requestId: (req.headers["x-request-id"] as string) || "unknown", }, - validationErrors: errors + validationErrors: errors, }); return; } @@ -173,64 +205,74 @@ export class AnalysisValidator extends BaseValidator { } catch (error) { res.status(500).json({ error: { - code: 'VALIDATION_EXCEPTION', - message: 'An error occurred during validation', + code: "VALIDATION_EXCEPTION", + message: "An error occurred during validation", timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || 'unknown' - } + requestId: (req.headers["x-request-id"] as string) || "unknown", + }, }); } } - private static validateSorobanProject(files: any[], metadata: any, errors: ValidationError[]): void { - const rustFiles = files.filter(f => f.language === 'rust'); - + private static validateSorobanProject( + files: any[], + metadata: any, + errors: ValidationError[], + ): void { + const rustFiles = files.filter((f) => f.language === "rust"); + if (rustFiles.length === 0) { errors.push({ - field: 'files', - message: 'Soroban projects must contain at least one Rust file', - constraint: 'sorobanRequiresRust' + field: "files", + message: "Soroban projects must contain at least one Rust file", + constraint: "sorobanRequiresRust", }); return; } // Check for Cargo.toml - const hasCargoToml = files.some(f => f.path === 'Cargo.toml' || f.path.endsWith('/Cargo.toml')); + const hasCargoToml = files.some( + (f) => f.path === "Cargo.toml" || f.path.endsWith("/Cargo.toml"), + ); if (!hasCargoToml) { errors.push({ - field: 'files', - message: 'Soroban projects must include a Cargo.toml file', - constraint: 'sorobanRequiresCargoToml' + field: "files", + message: "Soroban projects must include a Cargo.toml file", + constraint: "sorobanRequiresCargoToml", }); } // Validate Soroban-specific dependencies in Cargo.toml if present - const cargoToml = files.find(f => f.path.endsWith('Cargo.toml')); + const cargoToml = files.find((f) => f.path.endsWith("Cargo.toml")); if (cargoToml) { const content = cargoToml.content; - const hasSorobanSdk = content.includes('soroban-sdk') || content.includes('stellar-sdk'); - + const hasSorobanSdk = + content.includes("soroban-sdk") || content.includes("stellar-sdk"); + if (!hasSorobanSdk) { errors.push({ - field: 'metadata.dependencies', - message: 'Soroban projects must include soroban-sdk or stellar-sdk dependency', - constraint: 'sorobanRequiresSdk' + field: "metadata.dependencies", + message: + "Soroban projects must include soroban-sdk or stellar-sdk dependency", + constraint: "sorobanRequiresSdk", }); } } // Check for contract structure - const contractFiles = rustFiles.filter(f => - f.content.includes('#[contractimpl]') || - f.content.includes('soroban_sdk::contract') || - f.content.includes('ContractType') + const contractFiles = rustFiles.filter( + (f) => + f.content.includes("#[contractimpl]") || + f.content.includes("soroban_sdk::contract") || + f.content.includes("ContractType"), ); if (contractFiles.length === 0) { errors.push({ - field: 'files', - message: 'No Soroban contract implementation found. Files should contain #[contractimpl] or contract definitions.', - constraint: 'sorobanRequiresContract' + field: "files", + message: + "No Soroban contract implementation found. Files should contain #[contractimpl] or contract definitions.", + constraint: "sorobanRequiresContract", }); } } diff --git a/apps/api/src/validation/base.validator.ts b/apps/api/src/validation/base.validator.ts index 4497e4a..d0ed503 100644 --- a/apps/api/src/validation/base.validator.ts +++ b/apps/api/src/validation/base.validator.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction } from 'express'; +import { Request, Response, NextFunction } from "express"; export interface ValidationError { field: string; @@ -10,14 +10,17 @@ export interface ValidationError { export class BaseValidator { protected static readonly ETHEREUM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; protected static readonly STELLAR_ADDRESS_REGEX = /^[GC][A-Z0-9]{55}$/; - protected static readonly SOLANA_ADDRESS_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + protected static readonly SOLANA_ADDRESS_REGEX = + /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; protected static readonly MAX_GAS_LIMIT = 30000000; // 30M gas protected static readonly MIN_GAS_LIMIT = 21000; // Base transaction gas protected static readonly MAX_GAS_PRICE = 1000000000000; // 1000 gwei in wei protected static readonly MIN_GAS_PRICE = 1000000000; // 1 gwei in wei - protected static readonly SUPPORTED_CHAINS = [1, 56, 137, 42161, 10, 43114, 250]; // ETH, BSC, Polygon, Arbitrum, Optimism, Avalanche, Fantom + protected static readonly SUPPORTED_CHAINS = [ + 1, 56, 137, 42161, 10, 43114, 250, + ]; // ETH, BSC, Polygon, Arbitrum, Optimism, Avalanche, Fantom /** * Validates an Ethereum address format @@ -44,12 +47,13 @@ export class BaseValidator { * Validates a blockchain address based on chain */ static isValidAddress(address: string, chainId?: number): boolean { - if (!address || typeof address !== 'string') { + if (!address || typeof address !== "string") { return false; } // For Stellar/Soroban - if (chainId === 0 || !chainId) { // Assuming 0 or undefined for Stellar + if (chainId === 0 || !chainId) { + // Assuming 0 or undefined for Stellar return this.isValidStellarAddress(address); } @@ -66,16 +70,26 @@ export class BaseValidator { * Validates a gas limit value */ static isValidGasLimit(gasLimit: string | number): boolean { - const limit = typeof gasLimit === 'string' ? parseInt(gasLimit, 10) : gasLimit; - return !isNaN(limit) && limit >= this.MIN_GAS_LIMIT && limit <= this.MAX_GAS_LIMIT; + const limit = + typeof gasLimit === "string" ? parseInt(gasLimit, 10) : gasLimit; + return ( + !isNaN(limit) && + limit >= this.MIN_GAS_LIMIT && + limit <= this.MAX_GAS_LIMIT + ); } /** * Validates a gas price value (in wei) */ static isValidGasPrice(gasPrice: string | number): boolean { - const price = typeof gasPrice === 'string' ? parseInt(gasPrice, 10) : gasPrice; - return !isNaN(price) && price >= this.MIN_GAS_PRICE && price <= this.MAX_GAS_PRICE; + const price = + typeof gasPrice === "string" ? parseInt(gasPrice, 10) : gasPrice; + return ( + !isNaN(price) && + price >= this.MIN_GAS_PRICE && + price <= this.MAX_GAS_PRICE + ); } /** @@ -89,7 +103,7 @@ export class BaseValidator { * Validates a transaction type */ static isValidTransactionType(txType: string): boolean { - return ['transfer', 'contract-call', 'swap'].includes(txType); + return ["transfer", "contract-call", "swap"].includes(txType); } /** @@ -98,7 +112,7 @@ export class BaseValidator { static isValidUrl(url: string): boolean { try { const parsedUrl = new URL(url); - return ['http:', 'https:', 'git:'].includes(parsedUrl.protocol); + return ["http:", "https:", "git:"].includes(parsedUrl.protocol); } catch { return false; } @@ -108,7 +122,7 @@ export class BaseValidator { * Validates a positive number */ static isValidPositiveNumber(value: string | number): boolean { - const num = typeof value === 'string' ? parseFloat(value) : value; + const num = typeof value === "string" ? parseFloat(value) : value; return !isNaN(num) && num > 0; } @@ -118,16 +132,16 @@ export class BaseValidator { protected static sendValidationError( res: Response, errors: ValidationError[], - requestId?: string + requestId?: string, ): void { res.status(400).json({ error: { - code: 'VALIDATION_ERROR', - message: 'Request validation failed', + code: "VALIDATION_ERROR", + message: "Request validation failed", timestamp: new Date().toISOString(), - requestId: requestId || 'unknown' + requestId: requestId || "unknown", }, - validationErrors: errors + validationErrors: errors, }); } @@ -137,15 +151,15 @@ export class BaseValidator { protected static sendServerError( res: Response, error: any, - requestId?: string + requestId?: string, ): void { res.status(500).json({ error: { - code: 'VALIDATION_EXCEPTION', - message: 'An error occurred during validation', + code: "VALIDATION_EXCEPTION", + message: "An error occurred during validation", timestamp: new Date().toISOString(), - requestId: requestId || 'unknown' - } + requestId: requestId || "unknown", + }, }); } -} \ No newline at end of file +} diff --git a/apps/api/src/validation/cross-chain-gas.validator.ts b/apps/api/src/validation/cross-chain-gas.validator.ts index 2241093..bd7868c 100644 --- a/apps/api/src/validation/cross-chain-gas.validator.ts +++ b/apps/api/src/validation/cross-chain-gas.validator.ts @@ -1,31 +1,43 @@ -import { Request, Response, NextFunction } from 'express'; -import { BaseValidator, ValidationError } from './base.validator'; +import { Request, Response, NextFunction } from "express"; +import { BaseValidator, ValidationError } from "./base.validator"; export class CrossChainGasValidator extends BaseValidator { /** * Validates the transaction type query parameter */ - static validateTransactionType(req: Request, res: Response, next: NextFunction): void { + static validateTransactionType( + req: Request, + res: Response, + next: NextFunction, + ): void { const { txType } = req.query; const errors: ValidationError[] = []; if (!txType) { errors.push({ - field: 'txType', - message: 'Transaction type is required', - constraint: 'required' + field: "txType", + message: "Transaction type is required", + constraint: "required", }); - } else if (typeof txType !== 'string' || !this.isValidTransactionType(txType)) { + } else if ( + typeof txType !== "string" || + !this.isValidTransactionType(txType) + ) { errors.push({ - field: 'txType', - message: 'Invalid transaction type. Must be one of: transfer, contract-call, swap', + field: "txType", + message: + "Invalid transaction type. Must be one of: transfer, contract-call, swap", value: txType, - constraint: 'enum' + constraint: "enum", }); } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } @@ -35,30 +47,38 @@ export class CrossChainGasValidator extends BaseValidator { /** * Validates chain ID parameter in URL */ - static validateChainIdParam(req: Request, res: Response, next: NextFunction): void { + static validateChainIdParam( + req: Request, + res: Response, + next: NextFunction, + ): void { const { chainId } = req.params; const errors: ValidationError[] = []; if (!chainId) { errors.push({ - field: 'chainId', - message: 'Chain ID parameter is required', - constraint: 'required' + field: "chainId", + message: "Chain ID parameter is required", + constraint: "required", }); } else { const chainIdNum = parseInt(chainId, 10); if (isNaN(chainIdNum) || !this.isValidChainId(chainIdNum)) { errors.push({ - field: 'chainId', - message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(', ')}`, + field: "chainId", + message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(", ")}`, value: chainId, - constraint: 'chainId' + constraint: "chainId", }); } } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } @@ -68,49 +88,62 @@ export class CrossChainGasValidator extends BaseValidator { /** * Validates date range query parameters */ - static validateDateRange(req: Request, res: Response, next: NextFunction): void { + static validateDateRange( + req: Request, + res: Response, + next: NextFunction, + ): void { const { startDate, endDate } = req.query; const errors: ValidationError[] = []; - if (startDate && typeof startDate === 'string') { + if (startDate && typeof startDate === "string") { if (!this.isValidTimestamp(startDate)) { errors.push({ - field: 'startDate', - message: 'Invalid start date format. Must be ISO 8601 format', + field: "startDate", + message: "Invalid start date format. Must be ISO 8601 format", value: startDate, - constraint: 'timestamp' + constraint: "timestamp", }); } } - if (endDate && typeof endDate === 'string') { + if (endDate && typeof endDate === "string") { if (!this.isValidTimestamp(endDate)) { errors.push({ - field: 'endDate', - message: 'Invalid end date format. Must be ISO 8601 format', + field: "endDate", + message: "Invalid end date format. Must be ISO 8601 format", value: endDate, - constraint: 'timestamp' + constraint: "timestamp", }); } } - if (startDate && endDate && typeof startDate === 'string' && typeof endDate === 'string') { + if ( + startDate && + endDate && + typeof startDate === "string" && + typeof endDate === "string" + ) { const start = new Date(startDate); const end = new Date(endDate); if (start >= end) { errors.push({ - field: 'dateRange', - message: 'Start date must be before end date', - constraint: 'dateOrder' + field: "dateRange", + message: "Start date must be before end date", + constraint: "dateOrder", }); } } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } next(); } -} \ No newline at end of file +} diff --git a/apps/api/src/validation/failed-transaction.validator.ts b/apps/api/src/validation/failed-transaction.validator.ts index b030fba..a6374d0 100644 --- a/apps/api/src/validation/failed-transaction.validator.ts +++ b/apps/api/src/validation/failed-transaction.validator.ts @@ -1,12 +1,16 @@ -import { Request, Response, NextFunction } from 'express'; -import { BaseValidator, ValidationError } from './base.validator'; -import { TransactionAnalysisRequest } from '../schemas/failed-transaction.schema'; +import { Request, Response, NextFunction } from "express"; +import { BaseValidator, ValidationError } from "./base.validator"; +import { TransactionAnalysisRequest } from "../schemas/failed-transaction.schema"; export class FailedTransactionValidator extends BaseValidator { /** * Validates the transaction analysis request */ - static validateTransactionAnalysis(req: Request, res: Response, next: NextFunction): void { + static validateTransactionAnalysis( + req: Request, + res: Response, + next: NextFunction, + ): void { const body = req.body as TransactionAnalysisRequest; const errors: ValidationError[] = []; @@ -14,16 +18,16 @@ export class FailedTransactionValidator extends BaseValidator { // Validate wallet address if (!body.wallet) { errors.push({ - field: 'wallet', - message: 'Wallet address is required', - constraint: 'required' + field: "wallet", + message: "Wallet address is required", + constraint: "required", }); } else if (!this.isValidAddress(body.wallet)) { errors.push({ - field: 'wallet', - message: 'Invalid wallet address format', + field: "wallet", + message: "Invalid wallet address format", value: body.wallet, - constraint: 'addressFormat' + constraint: "addressFormat", }); } @@ -31,19 +35,19 @@ export class FailedTransactionValidator extends BaseValidator { if (body.chainIds) { if (!Array.isArray(body.chainIds)) { errors.push({ - field: 'chainIds', - message: 'Chain IDs must be an array', + field: "chainIds", + message: "Chain IDs must be an array", value: body.chainIds, - constraint: 'array' + constraint: "array", }); } else { body.chainIds.forEach((chainId, index) => { - if (typeof chainId !== 'number' || !this.isValidChainId(chainId)) { + if (typeof chainId !== "number" || !this.isValidChainId(chainId)) { errors.push({ field: `chainIds[${index}]`, - message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(', ')}`, + message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(", ")}`, value: chainId, - constraint: 'chainId' + constraint: "chainId", }); } }); @@ -52,21 +56,24 @@ export class FailedTransactionValidator extends BaseValidator { // Validate timeframe if (body.timeframe) { - if (body.timeframe.start && !this.isValidTimestamp(body.timeframe.start)) { + if ( + body.timeframe.start && + !this.isValidTimestamp(body.timeframe.start) + ) { errors.push({ - field: 'timeframe.start', - message: 'Invalid start timestamp format. Must be ISO 8601 format', + field: "timeframe.start", + message: "Invalid start timestamp format. Must be ISO 8601 format", value: body.timeframe.start, - constraint: 'timestamp' + constraint: "timestamp", }); } if (body.timeframe.end && !this.isValidTimestamp(body.timeframe.end)) { errors.push({ - field: 'timeframe.end', - message: 'Invalid end timestamp format. Must be ISO 8601 format', + field: "timeframe.end", + message: "Invalid end timestamp format. Must be ISO 8601 format", value: body.timeframe.end, - constraint: 'timestamp' + constraint: "timestamp", }); } @@ -75,59 +82,74 @@ export class FailedTransactionValidator extends BaseValidator { const endDate = new Date(body.timeframe.end); if (startDate >= endDate) { errors.push({ - field: 'timeframe', - message: 'Start timestamp must be before end timestamp', - constraint: 'timeframeOrder' + field: "timeframe", + message: "Start timestamp must be before end timestamp", + constraint: "timeframeOrder", }); } } } // Validate includeRecommendations - if (body.includeRecommendations !== undefined && typeof body.includeRecommendations !== 'boolean') { + if ( + body.includeRecommendations !== undefined && + typeof body.includeRecommendations !== "boolean" + ) { errors.push({ - field: 'includeRecommendations', - message: 'includeRecommendations must be a boolean', + field: "includeRecommendations", + message: "includeRecommendations must be a boolean", value: body.includeRecommendations, - constraint: 'boolean' + constraint: "boolean", }); } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } next(); } catch (error) { - this.sendServerError(res, error, req.headers['x-request-id'] as string); + this.sendServerError(res, error, req.headers["x-request-id"] as string); } } /** * Validates wallet address parameter in URL */ - static validateWalletParam(req: Request, res: Response, next: NextFunction): void { + static validateWalletParam( + req: Request, + res: Response, + next: NextFunction, + ): void { const { wallet } = req.params; const errors: ValidationError[] = []; if (!wallet) { errors.push({ - field: 'wallet', - message: 'Wallet address parameter is required', - constraint: 'required' + field: "wallet", + message: "Wallet address parameter is required", + constraint: "required", }); } else if (!this.isValidAddress(wallet)) { errors.push({ - field: 'wallet', - message: 'Invalid wallet address format', + field: "wallet", + message: "Invalid wallet address format", value: wallet, - constraint: 'addressFormat' + constraint: "addressFormat", }); } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } @@ -137,30 +159,40 @@ export class FailedTransactionValidator extends BaseValidator { /** * Validates chain IDs query parameter */ - static validateChainIdsQuery(req: Request, res: Response, next: NextFunction): void { + static validateChainIdsQuery( + req: Request, + res: Response, + next: NextFunction, + ): void { const { chainIds } = req.query; const errors: ValidationError[] = []; if (chainIds) { - const chainIdArray = (chainIds as string).split(',').map(id => parseInt(id.trim())); + const chainIdArray = (chainIds as string) + .split(",") + .map((id) => parseInt(id.trim())); chainIdArray.forEach((chainId, index) => { if (isNaN(chainId) || !this.isValidChainId(chainId)) { errors.push({ field: `chainIds[${index}]`, - message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(', ')}`, + message: `Invalid chain ID: ${chainId}. Supported chains: ${this.SUPPORTED_CHAINS.join(", ")}`, value: chainId, - constraint: 'chainId' + constraint: "chainId", }); } }); } if (errors.length > 0) { - this.sendValidationError(res, errors, req.headers['x-request-id'] as string); + this.sendValidationError( + res, + errors, + req.headers["x-request-id"] as string, + ); return; } next(); } -} \ No newline at end of file +} diff --git a/apps/api/src/validation/rpc/stellar/errors.ts b/apps/api/src/validation/rpc/stellar/errors.ts index 8ff58db..a73e41d 100644 --- a/apps/api/src/validation/rpc/stellar/errors.ts +++ b/apps/api/src/validation/rpc/stellar/errors.ts @@ -3,4 +3,4 @@ export class RpcValidationError extends Error { super(message); this.name = "RpcValidationError"; } -} \ No newline at end of file +} diff --git a/apps/api/src/validation/rpc/stellar/index.ts b/apps/api/src/validation/rpc/stellar/index.ts index 5a77b56..f2d52f0 100644 --- a/apps/api/src/validation/rpc/stellar/index.ts +++ b/apps/api/src/validation/rpc/stellar/index.ts @@ -1,3 +1,3 @@ export * from "./validator"; export * from "./types"; -export * from "./errors"; \ No newline at end of file +export * from "./errors"; diff --git a/apps/api/src/validation/rpc/stellar/schemas.ts b/apps/api/src/validation/rpc/stellar/schemas.ts index a70ec16..fa911e7 100644 --- a/apps/api/src/validation/rpc/stellar/schemas.ts +++ b/apps/api/src/validation/rpc/stellar/schemas.ts @@ -29,8 +29,9 @@ export function validateBaseRpcShape(payload: any): string | null { if (!isObject(err)) return "Invalid error object"; if (typeof err.code !== "number") return "Error code must be a number"; - if (typeof err.message !== "string") return "Error message must be a string"; + if (typeof err.message !== "string") + return "Error message must be a string"; } return null; -} \ No newline at end of file +} diff --git a/apps/api/src/validation/rpc/stellar/types.ts b/apps/api/src/validation/rpc/stellar/types.ts index 99615b4..ccfdf22 100644 --- a/apps/api/src/validation/rpc/stellar/types.ts +++ b/apps/api/src/validation/rpc/stellar/types.ts @@ -13,4 +13,4 @@ export type ValidateResult = { valid: boolean; data?: T; error?: string; -}; \ No newline at end of file +}; diff --git a/apps/api/src/validation/rpc/stellar/validator.ts b/apps/api/src/validation/rpc/stellar/validator.ts index dd92a8f..cbe02a9 100644 --- a/apps/api/src/validation/rpc/stellar/validator.ts +++ b/apps/api/src/validation/rpc/stellar/validator.ts @@ -47,4 +47,4 @@ export class SorobanRpcValidator { return validated.result as T; } -} \ No newline at end of file +} diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index b27193c..d78f847 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -1,6 +1,6 @@ -import axios from 'axios'; +import axios from "axios"; -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"; export const api = axios.create({ baseURL: API_BASE_URL, @@ -8,7 +8,7 @@ export const api = axios.create({ export const analysisService = { submitAnalysis: async (data: any) => { - const response = await api.post('/analysis', data); + const response = await api.post("/analysis", data); return response.data; }, getStatus: async (jobId: string) => { @@ -23,11 +23,11 @@ export const analysisService = { export const simulationService = { simulate: async (data: any) => { - const response = await api.post('/api/simulation/simulate', data); + const response = await api.post("/api/simulation/simulate", data); return response.data; }, compare: async (data: any) => { - const response = await api.post('/api/simulation/compare', data); + const response = await api.post("/api/simulation/compare", data); return response.data; }, }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8b0f57b..0e43ae8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) +}); diff --git a/docs/STELLAR_NETWORK_VALIDATION_RULE.md b/docs/STELLAR_NETWORK_VALIDATION_RULE.md new file mode 100644 index 0000000..56102a5 --- /dev/null +++ b/docs/STELLAR_NETWORK_VALIDATION_RULE.md @@ -0,0 +1,164 @@ +# Stellar Network Validation Rule + +## Overview + +The **Network Validation Rule** (`stellar-network-validation`) is a new security rule for GasGuard that detects Soroban contracts lacking network/environment validation. This rule helps prevent contracts from behaving incorrectly when deployed across different Stellar networks (mainnet, testnet, futurenet). + +## Problem Statement + +Soroban contracts may behave differently or incorrectly across different Stellar networks if they don't validate the network environment. Common issues include: + +- **Network-specific addresses**: Addresses generated or used may differ between networks +- **Different network behavior**: Ledger properties, fees, and behaviors vary between networks +- **Deployment errors**: Contracts designed for testnet might accidentally be deployed on mainnet +- **Security vulnerabilities**: Sensitive operations (transfers, mints, burns) should validate the network context + +## Implementation + +### Location + +``` +packages/rules/src/stellar/linting/networking/ +├── mod.rs # Module definition +└── network_validation.rs # Rule implementation +``` + +### Rule Details + +**Rule ID**: `stellar-network-validation` +**Name**: Stellar Network Validation +**Severity**: High (for general missing validation), Medium (for specific functions) + +### Detection Capabilities + +The rule performs multiple checks: + +1. **Environment Usage Without Network Validation** + - Detects contracts using `Env` without checking `network_passphrase()` + - Flags contracts that may behave differently across networks + +2. **Sensitive Functions Without Network Checks** + - Identifies critical functions (`transfer`, `withdraw`, `deposit`, `mint`, `burn`, `swap`) + - Checks if these functions validate the network before execution + +3. **Address Generation Without Network Context** + - Detects `Address::from()` or `Address::generate()` usage + - Ensures addresses are created with network awareness + +4. **Contract Implementation Validation** + - Checks entire contract implementations for any network validation + - Provides suggestions for adding proper validation + +### Suggested Validation Logic + +The rule suggests implementing network validation like: + +```rust +// Example: Network validation in a function +pub fn transfer(env: Env, to: Address, amount: u64) { + // ✅ Validate network + let network = env.ledger().network_passphrase(); + + // Optional: Assert expected network + // assert!(network.to_bytes() == expected_network_bytes, "Wrong network!"); + + // Perform transfer... +} +``` + +## Usage + +### Integration with Linter + +The rule is automatically registered in the `SorobanLinter`: + +```rust +// In packages/rules/src/stellar/linting/mod.rs +rules.push(Box::new(networking::NetworkValidationRule)); +``` + +### Example Violations + +#### ❌ Contract WITHOUT Network Validation + +```rust +#[contractimpl] +impl MyContract { + pub fn transfer(env: Env, to: Address, amount: u64) { + // No network validation - will trigger rule + } +} +``` + +**Violation**: + +- Rule: `stellar-network-validation` +- Description: "Function 'transfer' at line X performs sensitive operations without network validation" +- Severity: Medium + +#### ✅ Contract WITH Network Validation + +```rust +#[contractimpl] +impl MyContract { + pub fn transfer(env: Env, to: Address, amount: u64) { + let network = env.ledger().network_passphrase(); + // Network validation present - rule satisfied + } +} +``` + +## Testing + +The rule includes comprehensive tests covering: + +1. **Detection of missing network validation** - Ensures contracts using `Env` are flagged +2. **Recognition of proper validation** - Verifies contracts with network checks pass +3. **Sensitive function detection** - Tests transfer/mint/burn function checks +4. **Address generation validation** - Checks address creation patterns +5. **False positive prevention** - Ensures safe contracts aren't flagged + +Run tests: + +```bash +cargo test --package gasguard-rules network_validation +``` + +## Examples + +See example contracts demonstrating the rule: + +- **Without Validation**: `examples/contract_without_network_validation.rs` +- **With Validation**: `examples/contract_with_network_validation.rs` + +## Acceptance Criteria + +✅ **Missing network validation flagged** - Contracts lacking network checks are detected +✅ **Suggestions provided** - Rule suggests appropriate validation logic +✅ **Multiple detection points** - Checks at contract, function, and operation levels +✅ **Comprehensive testing** - Unit tests verify all detection scenarios +✅ **Integration complete** - Rule registered in SorobanLinter + +## Network Passphrases + +For reference, common Stellar network passphrases: + +- **Mainnet**: `"Public Global Stellar Network ; September 2015"` +- **Testnet**: `"Test SDF Network ; September 2015"` +- **Futurenet**: `"Test SDF Future Network ; October 2022"` + +## Benefits + +1. **Prevents Cross-Network Bugs**: Ensures contracts are aware of their deployment environment +2. **Security Enhancement**: Protects sensitive operations with network context validation +3. **Deployment Safety**: Helps prevent accidental testnet-to-mainnet deployment issues +4. **Best Practices**: Encourages developers to implement network-aware contracts + +## Future Enhancements + +Potential improvements: + +- Network-specific rule configurations +- Custom network passphrase validation +- Integration with deployment pipelines +- Network-dependent gas/fee estimation checks diff --git a/examples/contract_with_network_validation.rs b/examples/contract_with_network_validation.rs new file mode 100644 index 0000000..8af926e --- /dev/null +++ b/examples/contract_with_network_validation.rs @@ -0,0 +1,78 @@ +//! Example Soroban contract WITH proper network validation +//! This contract should NOT trigger the NetworkValidationRule + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Map}; + +// Network passphrases for validation +const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015"; +const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015"; + +#[contracttype] +pub struct TokenWithNetworkValidation { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, + pub is_testnet: bool, +} + +#[contractimpl] +impl TokenWithNetworkValidation { + /// Constructor - WITH network validation + pub fn initialize(env: Env, admin: Address, initial_supply: u64) -> Self { + // ✅ Validate network environment + let network = env.ledger().network_passphrase(); + let network_bytes = network.to_bytes(); + + // Check if we're on testnet or mainnet + let is_testnet = network_bytes.len() > 0; // Simplified check + + // Log or assert based on expected network + // For production, you might want: assert!(is_expected_network, "Wrong network!"); + + let mut balances = Map::new(&env); + balances.set(&admin, &initial_supply); + + Self { + admin, + total_supply: initial_supply, + balances, + is_testnet, + } + } + + /// Transfer function - WITH network validation + pub fn transfer(env: Env, from: Address, to: Address, amount: u64) { + // ✅ Validate network before sensitive operation + let network = env.ledger().network_passphrase(); + + // Optional: Add network-specific logic or logging + // Different networks might have different rules or limits + + // Perform transfer logic... + } + + /// Mint function - WITH network validation + pub fn mint(env: Env, to: Address, amount: u64) { + // ✅ Validate network + let network = env.ledger().network_passphrase(); + + // Could implement network-specific minting limits + // e.g., testnet allows higher limits for testing + + // Mint logic... + } + + /// Generate address - WITH network validation + pub fn create_sub_account(env: Env) -> Address { + // ✅ Network is validated through env + // Address generation is network-aware through the Env context + Address::generate(&env) + } + + /// Helper to check current network + pub fn get_network_info(env: Env) -> Symbol { + let network = env.ledger().network_passphrase(); + // Return network identifier + Symbol::new(&env, "stellar") + } +} diff --git a/examples/contract_without_network_validation.rs b/examples/contract_without_network_validation.rs new file mode 100644 index 0000000..4d07e8b --- /dev/null +++ b/examples/contract_without_network_validation.rs @@ -0,0 +1,51 @@ +//! Example Soroban contract WITHOUT network validation +//! This contract should trigger the NetworkValidationRule + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Map}; + +#[contracttype] +pub struct TokenWithoutNetworkValidation { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, +} + +#[contractimpl] +impl TokenWithoutNetworkValidation { + /// Constructor - Missing network validation + pub fn initialize(env: Env, admin: Address, initial_supply: u64) -> Self { + // ❌ No network validation + // This contract could behave differently on mainnet vs testnet + + let mut balances = Map::new(&env); + balances.set(&admin, &initial_supply); + + Self { + admin, + total_supply: initial_supply, + balances, + } + } + + /// Transfer function - Missing network validation + pub fn transfer(env: Env, from: Address, to: Address, amount: u64) { + // ❌ No network validation before sensitive operation + // This transfer could have different behavior across networks + + // Perform transfer logic... + } + + /// Mint function - Missing network validation + pub fn mint(env: Env, to: Address, amount: u64) { + // ❌ No network validation + // Minting on wrong network could be catastrophic + + // Mint logic... + } + + /// Generate address - Missing network validation + pub fn create_sub_account(env: Env) -> Address { + // ❌ Creating addresses without network validation + Address::generate(&env) + } +} diff --git a/libs/cache/cache.service.ts b/libs/cache/cache.service.ts index d43bed8..ac7a5bf 100644 --- a/libs/cache/cache.service.ts +++ b/libs/cache/cache.service.ts @@ -1,5 +1,5 @@ -import Redis from 'ioredis'; -import { redisConfig } from '../../packages/config/redis'; +import Redis from "ioredis"; +import { redisConfig } from "../../packages/config/redis"; export class CacheService { private redis: Redis; @@ -24,9 +24,13 @@ export class CacheService { } } - async set(key: string, value: any, ttl: number = redisConfig.ttl): Promise { - const data = typeof value === 'string' ? value : JSON.stringify(value); - await this.redis.set(key, data, 'EX', ttl); + async set( + key: string, + value: any, + ttl: number = redisConfig.ttl, + ): Promise { + const data = typeof value === "string" ? value : JSON.stringify(value); + await this.redis.set(key, data, "EX", ttl); } async del(key: string): Promise { diff --git a/libs/cache/file-hash.service.ts b/libs/cache/file-hash.service.ts index d76018b..11512be 100644 --- a/libs/cache/file-hash.service.ts +++ b/libs/cache/file-hash.service.ts @@ -1,7 +1,7 @@ -import * as crypto from 'crypto'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import { CacheService } from './cache.service'; +import * as crypto from "crypto"; +import { promises as fs } from "fs"; +import * as path from "path"; +import { CacheService } from "./cache.service"; export interface FileHashInfo { filePath: string; @@ -26,9 +26,12 @@ export class FileHashService { async generateFileHash(filePath: string): Promise { try { const stats = await fs.stat(filePath); - const content = await fs.readFile(filePath, 'utf-8'); - const contentHash = crypto.createHash('sha256').update(content).digest('hex'); - + const content = await fs.readFile(filePath, "utf-8"); + const contentHash = crypto + .createHash("sha256") + .update(content) + .digest("hex"); + return { filePath: path.normalize(filePath), contentHash, @@ -36,15 +39,21 @@ export class FileHashService { fileSize: stats.size, }; } catch (error) { - throw new Error(`Failed to generate hash for ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `Failed to generate hash for ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); } } /** * Generate hashes for multiple files in parallel */ - async generateMultipleFileHashes(filePaths: string[]): Promise { - const hashPromises = filePaths.map(filePath => this.generateFileHash(filePath)); + async generateMultipleFileHashes( + filePaths: string[], + ): Promise { + const hashPromises = filePaths.map((filePath) => + this.generateFileHash(filePath), + ); return Promise.all(hashPromises); } @@ -53,19 +62,23 @@ export class FileHashService { */ async getCachedHashes(repoPath: string): Promise> { const cacheKey = `file-hashes:${this.normalizeRepoPath(repoPath)}`; - const cached = await this.cacheService.get>(cacheKey); - + const cached = + await this.cacheService.get>(cacheKey); + if (!cached) { return new Map(); } - + return new Map(Object.entries(cached)); } /** * Cache hash information for a repository */ - async cacheHashes(repoPath: string, hashes: Map): Promise { + async cacheHashes( + repoPath: string, + hashes: Map, + ): Promise { const cacheKey = `file-hashes:${this.normalizeRepoPath(repoPath)}`; const hashObject = Object.fromEntries(hashes); await this.cacheService.set(cacheKey, hashObject, 86400); // 24 hours TTL @@ -76,11 +89,14 @@ export class FileHashService { */ async compareWithCache( repoPath: string, - currentFiles: string[] + currentFiles: string[], ): Promise { const cachedHashes = await this.getCachedHashes(repoPath); const currentHashes = new Map( - (await this.generateMultipleFileHashes(currentFiles)).map(info => [info.filePath, info]) + (await this.generateMultipleFileHashes(currentFiles)).map((info) => [ + info.filePath, + info, + ]), ); const unchanged: FileHashInfo[] = []; @@ -91,7 +107,7 @@ export class FileHashService { // Check for modified and unchanged files for (const [filePath, currentInfo] of currentHashes) { const cachedInfo = cachedHashes.get(filePath); - + if (!cachedInfo) { added.push(currentInfo); } else if (cachedInfo.contentHash === currentInfo.contentHash) { @@ -121,16 +137,19 @@ export class FileHashService { */ async findDependentFiles( modifiedFiles: string[], - allFiles: string[] + allFiles: string[], ): Promise { const dependentFiles: Set = new Set(); - + for (const modifiedFile of modifiedFiles) { // Simple dependency detection based on file extensions and common patterns - const dependencies = await this.detectDependencies(modifiedFile, allFiles); - dependencies.forEach(dep => dependentFiles.add(dep)); + const dependencies = await this.detectDependencies( + modifiedFile, + allFiles, + ); + dependencies.forEach((dep) => dependentFiles.add(dep)); } - + return Array.from(dependentFiles); } @@ -139,42 +158,49 @@ export class FileHashService { */ private async detectDependencies( sourceFile: string, - allFiles: string[] + allFiles: string[], ): Promise { const dependencies: string[] = []; const sourceExt = path.extname(sourceFile); - + try { - const content = await fs.readFile(sourceFile, 'utf-8'); + const content = await fs.readFile(sourceFile, "utf-8"); const sourceDir = path.dirname(sourceFile); - + // Rust dependencies - if (sourceExt === '.rs') { + if (sourceExt === ".rs") { const importMatches = content.match(/use\s+([^;]+);/g) || []; for (const importStmt of importMatches) { - const modulePath = importStmt.replace(/use\s+([^;]+);/, '$1').trim(); - const possibleFiles = this.resolveRustImport(modulePath, sourceDir, allFiles); + const modulePath = importStmt.replace(/use\s+([^;]+);/, "$1").trim(); + const possibleFiles = this.resolveRustImport( + modulePath, + sourceDir, + allFiles, + ); dependencies.push(...possibleFiles); } } - + // Solidity/Vyper dependencies - if (sourceExt === '.sol' || sourceExt === '.vy') { + if (sourceExt === ".sol" || sourceExt === ".vy") { const importMatches = content.match(/import\s+["']([^"']+)["']/g) || []; for (const importStmt of importMatches) { const importPath = importStmt.match(/import\s+["']([^"']+)["']/)?.[1]; if (importPath) { - const possibleFiles = this.resolveSolidityImport(importPath, sourceDir, allFiles); + const possibleFiles = this.resolveSolidityImport( + importPath, + sourceDir, + allFiles, + ); dependencies.push(...possibleFiles); } } } - } catch (error) { // If we can't read the file, skip dependency detection } - - return dependencies.filter(dep => allFiles.includes(dep)); + + return dependencies.filter((dep) => allFiles.includes(dep)); } /** @@ -183,17 +209,30 @@ export class FileHashService { private resolveRustImport( modulePath: string, sourceDir: string, - allFiles: string[] + allFiles: string[], ): string[] { const possibleFiles: string[] = []; - + // Handle different Rust import patterns - if (modulePath.startsWith('crate::')) { + if (modulePath.startsWith("crate::")) { // Local crate imports - const relativePath = modulePath.replace('crate::', '').replace(/::/g, '/'); - const possibleFile = path.join(sourceDir, '..', 'src', `${relativePath}.rs`); - const possibleModDir = path.join(sourceDir, '..', 'src', relativePath, 'mod.rs'); - + const relativePath = modulePath + .replace("crate::", "") + .replace(/::/g, "/"); + const possibleFile = path.join( + sourceDir, + "..", + "src", + `${relativePath}.rs`, + ); + const possibleModDir = path.join( + sourceDir, + "..", + "src", + relativePath, + "mod.rs", + ); + if (allFiles.includes(possibleFile)) { possibleFiles.push(possibleFile); } @@ -201,7 +240,7 @@ export class FileHashService { possibleFiles.push(possibleModDir); } } - + return possibleFiles; } @@ -211,27 +250,27 @@ export class FileHashService { private resolveSolidityImport( importPath: string, sourceDir: string, - allFiles: string[] + allFiles: string[], ): string[] { const possibleFiles: string[] = []; - + // Relative imports - if (importPath.startsWith('./') || importPath.startsWith('../')) { + if (importPath.startsWith("./") || importPath.startsWith("../")) { const absolutePath = path.resolve(sourceDir, importPath); - const withExt = allFiles.find(f => f.startsWith(absolutePath)); + const withExt = allFiles.find((f) => f.startsWith(absolutePath)); if (withExt) { possibleFiles.push(withExt); } } - + // Try adding .sol extension if not present - if (!importPath.endsWith('.sol')) { + if (!importPath.endsWith(".sol")) { const withSolExt = path.resolve(sourceDir, `${importPath}.sol`); if (allFiles.includes(withSolExt)) { possibleFiles.push(withSolExt); } } - + return possibleFiles; } @@ -239,14 +278,14 @@ export class FileHashService { * Normalize repository path for consistent caching */ private normalizeRepoPath(repoPath: string): string { - return path.normalize(repoPath).replace(/\\/g, ':').replace(/\//g, ':'); + return path.normalize(repoPath).replace(/\\/g, ":").replace(/\//g, ":"); } /** * Get supported file extensions for analysis */ getSupportedExtensions(): string[] { - return ['.rs', '.sol', '.vy']; + return [".rs", ".sol", ".vy"]; } /** @@ -254,7 +293,7 @@ export class FileHashService { */ filterSupportedFiles(filePaths: string[]): string[] { const supportedExts = this.getSupportedExtensions(); - return filePaths.filter(filePath => { + return filePaths.filter((filePath) => { const ext = path.extname(filePath); return supportedExts.includes(ext); }); diff --git a/libs/cache/incremental-cache.service.ts b/libs/cache/incremental-cache.service.ts index 00a8619..0782b92 100644 --- a/libs/cache/incremental-cache.service.ts +++ b/libs/cache/incremental-cache.service.ts @@ -1,5 +1,9 @@ -import { CacheService } from './cache.service'; -import { FileHashService, FileHashInfo, HashComparisonResult } from './file-hash.service'; +import { CacheService } from "./cache.service"; +import { + FileHashService, + FileHashInfo, + HashComparisonResult, +} from "./file-hash.service"; export interface AnalysisCacheEntry { filePath: string; @@ -31,20 +35,23 @@ export class IncrementalCacheService { constructor( private cacheService: CacheService, - private fileHashService: FileHashService + private fileHashService: FileHashService, ) {} /** * Get cached analysis results for a repository */ - async getCachedAnalysis(repoPath: string): Promise> { + async getCachedAnalysis( + repoPath: string, + ): Promise> { const cacheKey = `analysis-cache:${this.normalizeRepoPath(repoPath)}`; - const cached = await this.cacheService.get>(cacheKey); - + const cached = + await this.cacheService.get>(cacheKey); + if (!cached) { return new Map(); } - + return new Map(Object.entries(cached)); } @@ -53,7 +60,7 @@ export class IncrementalCacheService { */ async cacheAnalysisResults( repoPath: string, - results: Map + results: Map, ): Promise { const cacheKey = `analysis-cache:${this.normalizeRepoPath(repoPath)}`; const resultObject = Object.fromEntries(results); @@ -69,28 +76,42 @@ export class IncrementalCacheService { nodes: Record; reverseNodes: Record; }>(cacheKey); - + if (!cached) { return { nodes: new Map(), reverseNodes: new Map(), }; } - + return { - nodes: new Map(Object.entries(cached.nodes).map(([k, v]) => [k, new Set(v)])), - reverseNodes: new Map(Object.entries(cached.reverseNodes).map(([k, v]) => [k, new Set(v)])), + nodes: new Map( + Object.entries(cached.nodes).map(([k, v]) => [k, new Set(v)]), + ), + reverseNodes: new Map( + Object.entries(cached.reverseNodes).map(([k, v]) => [k, new Set(v)]), + ), }; } /** * Cache dependency graph for a repository */ - async cacheDependencyGraph(repoPath: string, graph: DependencyGraph): Promise { + async cacheDependencyGraph( + repoPath: string, + graph: DependencyGraph, + ): Promise { const cacheKey = `dependency-graph:${this.normalizeRepoPath(repoPath)}`; const serializableGraph = { - nodes: Object.fromEntries(Array.from(graph.nodes.entries()).map(([k, v]) => [k, Array.from(v)])), - reverseNodes: Object.fromEntries(Array.from(graph.reverseNodes.entries()).map(([k, v]) => [k, Array.from(v)])), + nodes: Object.fromEntries( + Array.from(graph.nodes.entries()).map(([k, v]) => [k, Array.from(v)]), + ), + reverseNodes: Object.fromEntries( + Array.from(graph.reverseNodes.entries()).map(([k, v]) => [ + k, + Array.from(v), + ]), + ), }; await this.cacheService.set(cacheKey, serializableGraph, 86400); // 24 hours TTL } @@ -101,69 +122,82 @@ export class IncrementalCacheService { async performIncrementalAnalysis( repoPath: string, allFiles: string[], - analysisFunction: (files: string[]) => Promise + analysisFunction: (files: string[]) => Promise, ): Promise { const startTime = Date.now(); - + // Filter supported files const supportedFiles = this.fileHashService.filterSupportedFiles(allFiles); - + // Get hash comparison - const hashComparison = await this.fileHashService.compareWithCache(repoPath, supportedFiles); - + const hashComparison = await this.fileHashService.compareWithCache( + repoPath, + supportedFiles, + ); + // Get cached analysis results const cachedAnalysis = await this.getCachedAnalysis(repoPath); - + // Get dependency graph const dependencyGraph = await this.getCachedDependencyGraph(repoPath); - + // Determine which files need re-analysis const filesToReanalyze = new Set(); - + // Add modified files - hashComparison.modified.forEach(file => filesToReanalyze.add(file.filePath)); - + hashComparison.modified.forEach((file) => + filesToReanalyze.add(file.filePath), + ); + // Add files that depend on modified files for (const modifiedFile of hashComparison.modified) { - const dependents = dependencyGraph.reverseNodes.get(modifiedFile.filePath); + const dependents = dependencyGraph.reverseNodes.get( + modifiedFile.filePath, + ); if (dependents) { - dependents.forEach(dependent => filesToReanalyze.add(dependent)); + dependents.forEach((dependent) => filesToReanalyze.add(dependent)); } } - + // Add new files - hashComparison.added.forEach(file => filesToReanalyze.add(file.filePath)); - + hashComparison.added.forEach((file) => filesToReanalyze.add(file.filePath)); + // Remove deleted files from cache - hashComparison.deleted.forEach(deletedFile => { + hashComparison.deleted.forEach((deletedFile) => { cachedAnalysis.delete(deletedFile); }); - + // Filter out unchanged files that have valid cache entries - const filesToAnalyze = Array.from(filesToReanalyze).filter(filePath => { + const filesToAnalyze = Array.from(filesToReanalyze).filter((filePath) => { const cached = cachedAnalysis.get(filePath); if (!cached) return true; - + // Check if the cached result is still valid - const currentFile = hashComparison.unchanged.find(f => f.filePath === filePath); + const currentFile = hashComparison.unchanged.find( + (f) => f.filePath === filePath, + ); return !currentFile || currentFile.contentHash !== cached.contentHash; }); - + // Perform analysis on files that need it let newResults: any[] = []; if (filesToAnalyze.length > 0) { newResults = await analysisFunction(filesToAnalyze); } - + // Update cache with new results for (const result of newResults) { - const fileInfo = hashComparison.modified.find(f => f.filePath === result.source) || - hashComparison.added.find(f => f.filePath === result.source); - + const fileInfo = + hashComparison.modified.find((f) => f.filePath === result.source) || + hashComparison.added.find((f) => f.filePath === result.source); + if (fileInfo) { // Build dependency graph for this file - const dependencies = await this.fileHashService.findDependentFiles([fileInfo.filePath], supportedFiles); - + const dependencies = await this.fileHashService.findDependentFiles( + [fileInfo.filePath], + supportedFiles, + ); + const cacheEntry: AnalysisCacheEntry = { filePath: fileInfo.filePath, contentHash: fileInfo.contentHash, @@ -171,12 +205,12 @@ export class IncrementalCacheService { timestamp: Date.now(), dependencies, }; - + cachedAnalysis.set(fileInfo.filePath, cacheEntry); - + // Update dependency graph dependencyGraph.nodes.set(fileInfo.filePath, new Set(dependencies)); - dependencies.forEach(dep => { + dependencies.forEach((dep) => { if (!dependencyGraph.reverseNodes.has(dep)) { dependencyGraph.reverseNodes.set(dep, new Set()); } @@ -184,21 +218,24 @@ export class IncrementalCacheService { }); } } - + // Save updated cache and dependency graph await this.cacheAnalysisResults(repoPath, cachedAnalysis); await this.cacheDependencyGraph(repoPath, dependencyGraph); - + // Update file hashes cache const currentHashes = new Map(); - [...hashComparison.modified, ...hashComparison.added, ...hashComparison.unchanged] - .forEach(fileInfo => currentHashes.set(fileInfo.filePath, fileInfo)); + [ + ...hashComparison.modified, + ...hashComparison.added, + ...hashComparison.unchanged, + ].forEach((fileInfo) => currentHashes.set(fileInfo.filePath, fileInfo)); await this.fileHashService.cacheHashes(repoPath, currentHashes); - + const analysisTime = Date.now() - startTime; const totalFiles = supportedFiles.length; const cacheHitRate = (totalFiles - filesToAnalyze.length) / totalFiles; - + return { cachedResults: Array.from(cachedAnalysis.values()), newResults, @@ -214,11 +251,11 @@ export class IncrementalCacheService { */ async invalidateFiles(repoPath: string, filePaths: string[]): Promise { const cachedAnalysis = await this.getCachedAnalysis(repoPath); - - filePaths.forEach(filePath => { + + filePaths.forEach((filePath) => { cachedAnalysis.delete(filePath); }); - + await this.cacheAnalysisResults(repoPath, cachedAnalysis); } @@ -227,7 +264,7 @@ export class IncrementalCacheService { */ async clearCache(repoPath: string): Promise { const normalizedPath = this.normalizeRepoPath(repoPath); - + await Promise.all([ this.cacheService.del(`analysis-cache:${normalizedPath}`), this.cacheService.del(`dependency-graph:${normalizedPath}`), @@ -246,19 +283,21 @@ export class IncrementalCacheService { }> { const cachedAnalysis = await this.getCachedAnalysis(repoPath); const dependencyGraph = await this.getCachedDependencyGraph(repoPath); - + let cacheAge = null; if (cachedAnalysis.size > 0) { - const oldestEntry = Array.from(cachedAnalysis.values()) - .reduce((oldest, current) => current.timestamp < oldest.timestamp ? current : oldest); + const oldestEntry = Array.from(cachedAnalysis.values()).reduce( + (oldest, current) => + current.timestamp < oldest.timestamp ? current : oldest, + ); cacheAge = Date.now() - oldestEntry.timestamp; } - + let dependencyEdges = 0; - dependencyGraph.nodes.forEach(deps => { + dependencyGraph.nodes.forEach((deps) => { dependencyEdges += deps.size; }); - + return { totalCachedFiles: cachedAnalysis.size, cacheAge, @@ -271,32 +310,36 @@ export class IncrementalCacheService { * Normalize repository path for consistent caching */ private normalizeRepoPath(repoPath: string): string { - return repoPath.replace(/\\/g, ':').replace(/\//g, ':'); + return repoPath.replace(/\\/g, ":").replace(/\//g, ":"); } /** * Check if incremental analysis is beneficial for this repository */ - async shouldUseIncrementalAnalysis(repoPath: string, fileCount: number): Promise { + async shouldUseIncrementalAnalysis( + repoPath: string, + fileCount: number, + ): Promise { // Use incremental analysis if: // 1. There are more than 10 files // 2. We have some cached data // 3. The cache is not too old (older than 7 days) - + if (fileCount <= 10) { return false; } - + const stats = await this.getCacheStats(repoPath); - + if (stats.totalCachedFiles === 0) { return false; } - - if (stats.cacheAge && stats.cacheAge > 7 * 24 * 60 * 60 * 1000) { // 7 days + + if (stats.cacheAge && stats.cacheAge > 7 * 24 * 60 * 60 * 1000) { + // 7 days return false; } - + return true; } } diff --git a/libs/cache/index.ts b/libs/cache/index.ts index b33c0f5..305b051 100644 --- a/libs/cache/index.ts +++ b/libs/cache/index.ts @@ -1,3 +1,3 @@ -export * from './cache.service'; -export * from './file-hash.service'; -export * from './incremental-cache.service'; +export * from "./cache.service"; +export * from "./file-hash.service"; +export * from "./incremental-cache.service"; diff --git a/libs/chains/base-adapter.ts b/libs/chains/base-adapter.ts index 35439c7..401bc23 100644 --- a/libs/chains/base-adapter.ts +++ b/libs/chains/base-adapter.ts @@ -14,6 +14,10 @@ export interface OpcodeTrace { } export interface ChainAdapter { - simulate(code: string, method: string, params: any[]): Promise; + simulate( + code: string, + method: string, + params: any[], + ): Promise; getChainId(): string; } diff --git a/libs/chains/evm-adapter.ts b/libs/chains/evm-adapter.ts index 026134b..f5b0d17 100644 --- a/libs/chains/evm-adapter.ts +++ b/libs/chains/evm-adapter.ts @@ -1,21 +1,29 @@ -import { ChainAdapter, SimulationResult, OpcodeTrace } from './base-adapter'; -import { RpcClient } from '@rpc/index'; +import { ChainAdapter, SimulationResult, OpcodeTrace } from "./base-adapter"; +import { RpcClient } from "@rpc/index"; export class EvmAdapter implements ChainAdapter { constructor(private rpcClient: RpcClient) {} getChainId(): string { - return 'evm'; + return "evm"; } - async simulate(code: string, method: string, params: any[]): Promise { + async simulate( + code: string, + method: string, + params: any[], + ): Promise { // In a real implementation, we would use debug_traceCall to get opcode-level traces // For this demonstration, we'll simulate the response format try { - const traceResponse = await this.rpcClient.call('debug_traceCall', [{ - to: '0x0000000000000000000000000000000000000000', // Placeholder - data: code, // This would be the encoded call - }, 'latest', { tracer: 'callTracer' }]); + const traceResponse = await this.rpcClient.call("debug_traceCall", [ + { + to: "0x0000000000000000000000000000000000000000", // Placeholder + data: code, // This would be the encoded call + }, + "latest", + { tracer: "callTracer" }, + ]); // Simplified mapping for demonstration return { @@ -29,9 +37,9 @@ export class EvmAdapter implements ChainAdapter { return { gasUsed: 21000 + Math.floor(Math.random() * 50000), opcodes: [ - { opcode: 'PUSH1', gasCost: 3, pc: 0, depth: 0 }, - { opcode: 'MSTORE', gasCost: 3, pc: 2, depth: 0 }, - { opcode: 'CALL', gasCost: 700, pc: 5, depth: 0 }, + { opcode: "PUSH1", gasCost: 3, pc: 0, depth: 0 }, + { opcode: "MSTORE", gasCost: 3, pc: 2, depth: 0 }, + { opcode: "CALL", gasCost: 700, pc: 5, depth: 0 }, ], reverted: false, }; @@ -39,7 +47,7 @@ export class EvmAdapter implements ChainAdapter { } private mapOpcodes(logs: any[]): OpcodeTrace[] { - return logs.map(log => ({ + return logs.map((log) => ({ opcode: log.op, gasCost: log.gasCost, pc: log.pc, diff --git a/libs/chains/index.ts b/libs/chains/index.ts index 7bbca1b..7fe1a4d 100644 --- a/libs/chains/index.ts +++ b/libs/chains/index.ts @@ -1,3 +1,3 @@ -export * from './base-adapter'; -export * from './evm-adapter'; -export * from './soroban-adapter'; +export * from "./base-adapter"; +export * from "./evm-adapter"; +export * from "./soroban-adapter"; diff --git a/libs/chains/soroban-adapter.ts b/libs/chains/soroban-adapter.ts index b0ad026..0b7ef31 100644 --- a/libs/chains/soroban-adapter.ts +++ b/libs/chains/soroban-adapter.ts @@ -1,17 +1,21 @@ -import { ChainAdapter, SimulationResult, OpcodeTrace } from './base-adapter'; -import { RpcClient } from '@rpc/index'; +import { ChainAdapter, SimulationResult, OpcodeTrace } from "./base-adapter"; +import { RpcClient } from "@rpc/index"; export class SorobanAdapter implements ChainAdapter { constructor(private rpcClient: RpcClient) {} getChainId(): string { - return 'soroban'; + return "soroban"; } - async simulate(code: string, method: string, params: any[]): Promise { + async simulate( + code: string, + method: string, + params: any[], + ): Promise { try { // Soroban uses simulateTransaction - const response = await this.rpcClient.call('simulateTransaction', [code]); + const response = await this.rpcClient.call("simulateTransaction", [code]); return { gasUsed: response.cost?.cpuInsns || 0, // Using CPU instructions as gas analog diff --git a/libs/engine/analyzers/index.ts b/libs/engine/analyzers/index.ts index 9d3977a..5840e00 100644 --- a/libs/engine/analyzers/index.ts +++ b/libs/engine/analyzers/index.ts @@ -1,5 +1,2 @@ - -export { SolidityAnalyzer } from './solidity-analyzer'; -export { RustAnalyzer } from './rust-analyzer'; - - +export { SolidityAnalyzer } from "./solidity-analyzer"; +export { RustAnalyzer } from "./rust-analyzer"; diff --git a/libs/engine/analyzers/rust-analyzer.ts b/libs/engine/analyzers/rust-analyzer.ts index 0667242..9f90bc4 100644 --- a/libs/engine/analyzers/rust-analyzer.ts +++ b/libs/engine/analyzers/rust-analyzer.ts @@ -7,20 +7,20 @@ import { AnalyzerConfig, Finding, Severity, -} from '../core/analyzer-interface'; - +} from "../core/analyzer-interface"; export class RustAnalyzer extends BaseAnalyzer implements Analyzer { private rules: Rule[] = [ { - id: 'rust-001', - name: 'Inefficient String Concatenation', - description: 'Detects inefficient string concatenation that could use String::with_capacity', + id: "rust-001", + name: "Inefficient String Concatenation", + description: + "Detects inefficient string concatenation that could use String::with_capacity", severity: Severity.MEDIUM, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['strings', 'memory', 'performance'], - documentationUrl: 'https://docs.gasguard.dev/rules/rust-001', + tags: ["strings", "memory", "performance"], + documentationUrl: "https://docs.gasguard.dev/rules/rust-001", estimatedGasImpact: { min: 50, max: 500, @@ -28,14 +28,15 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'rust-002', - name: 'Unnecessary Clone', - description: 'Detects unnecessary .clone() calls that increase resource usage', + id: "rust-002", + name: "Unnecessary Clone", + description: + "Detects unnecessary .clone() calls that increase resource usage", severity: Severity.HIGH, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['memory', 'clone', 'performance'], - documentationUrl: 'https://docs.gasguard.dev/rules/rust-002', + tags: ["memory", "clone", "performance"], + documentationUrl: "https://docs.gasguard.dev/rules/rust-002", estimatedGasImpact: { min: 100, max: 2000, @@ -43,14 +44,15 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'rust-003', - name: 'Vec allocation without capacity', - description: 'Vec::new() without with_capacity can cause multiple reallocations', + id: "rust-003", + name: "Vec allocation without capacity", + description: + "Vec::new() without with_capacity can cause multiple reallocations", severity: Severity.MEDIUM, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['collections', 'memory', 'performance'], - documentationUrl: 'https://docs.gasguard.dev/rules/rust-003', + tags: ["collections", "memory", "performance"], + documentationUrl: "https://docs.gasguard.dev/rules/rust-003", estimatedGasImpact: { min: 200, max: 1500, @@ -58,14 +60,15 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'soroban-001', - name: 'Inefficient Storage Access', - description: 'Multiple storage reads for the same key in Soroban contracts', + id: "soroban-001", + name: "Inefficient Storage Access", + description: + "Multiple storage reads for the same key in Soroban contracts", severity: Severity.HIGH, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['soroban', 'storage', 'ledger'], - documentationUrl: 'https://docs.gasguard.dev/rules/soroban-001', + tags: ["soroban", "storage", "ledger"], + documentationUrl: "https://docs.gasguard.dev/rules/soroban-001", estimatedGasImpact: { min: 500, max: 5000, @@ -73,14 +76,14 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'soroban-002', - name: 'Unbounded Loop in Contract', - description: 'Loop without clear bounds can cause CPU limit exhaustion', + id: "soroban-002", + name: "Unbounded Loop in Contract", + description: "Loop without clear bounds can cause CPU limit exhaustion", severity: Severity.CRITICAL, - category: 'security', + category: "security", enabled: true, - tags: ['soroban', 'loops', 'cpu-limits'], - documentationUrl: 'https://docs.gasguard.dev/rules/soroban-002', + tags: ["soroban", "loops", "cpu-limits"], + documentationUrl: "https://docs.gasguard.dev/rules/soroban-002", estimatedGasImpact: { min: 1000, max: 10000, @@ -90,15 +93,21 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { ]; getName(): string { - return 'RustAnalyzer'; + return "RustAnalyzer"; } getVersion(): string { - return '1.0.0'; + return "1.0.0"; } supportsLanguage(language: Language | string): boolean { - return language === Language.RUST || language === Language.SOROBAN || language === 'rust' || language === 'rs' || language === 'soroban'; + return ( + language === Language.RUST || + language === Language.SOROBAN || + language === "rust" || + language === "rs" || + language === "soroban" + ); } getSupportedLanguages(): Language[] { @@ -112,7 +121,7 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { async analyze( code: string, filePath: string, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise { const startTime = Date.now(); const findings: Finding[] = []; @@ -139,103 +148,120 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { const isSorobanContract = this.isSorobanContract(code); // Rule: rust-001 - Inefficient string concatenation - if (this.isRuleEnabled('rust-001', config)) { + if (this.isRuleEnabled("rust-001", config)) { const inefficientStrings = this.detectInefficientStringOps(code); - findings.push(...inefficientStrings.map(location => ({ - ruleId: 'rust-001', - message: 'Inefficient string concatenation. Consider using String::with_capacity', - severity: this.getRuleSeverity('rust-001', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 150, - suggestedFix: { - description: 'Pre-allocate string capacity to avoid reallocations', - codeSnippet: 'let mut result = String::with_capacity(estimated_size);\nresult.push_str(&str1);\nresult.push_str(&str2);', - documentationUrl: 'https://docs.gasguard.dev/rules/rust-001', - }, - }))); + findings.push( + ...inefficientStrings.map((location) => ({ + ruleId: "rust-001", + message: + "Inefficient string concatenation. Consider using String::with_capacity", + severity: this.getRuleSeverity("rust-001", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 150, + suggestedFix: { + description: + "Pre-allocate string capacity to avoid reallocations", + codeSnippet: + "let mut result = String::with_capacity(estimated_size);\nresult.push_str(&str1);\nresult.push_str(&str2);", + documentationUrl: "https://docs.gasguard.dev/rules/rust-001", + }, + })), + ); } // Rule: rust-002 - Unnecessary clone - if (this.isRuleEnabled('rust-002', config)) { + if (this.isRuleEnabled("rust-002", config)) { const unnecessaryClones = this.detectUnnecessaryClones(code); - findings.push(...unnecessaryClones.map(location => ({ - ruleId: 'rust-002', - message: 'Unnecessary .clone() detected. Consider using references', - severity: this.getRuleSeverity('rust-002', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 500, - suggestedFix: { - description: 'Use references (&) instead of cloning when possible', - documentationUrl: 'https://docs.gasguard.dev/rules/rust-002', - }, - }))); + findings.push( + ...unnecessaryClones.map((location) => ({ + ruleId: "rust-002", + message: "Unnecessary .clone() detected. Consider using references", + severity: this.getRuleSeverity("rust-002", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 500, + suggestedFix: { + description: + "Use references (&) instead of cloning when possible", + documentationUrl: "https://docs.gasguard.dev/rules/rust-002", + }, + })), + ); } // Rule: rust-003 - Vec without capacity - if (this.isRuleEnabled('rust-003', config)) { + if (this.isRuleEnabled("rust-003", config)) { const vecWithoutCapacity = this.detectVecWithoutCapacity(code); - findings.push(...vecWithoutCapacity.map(location => ({ - ruleId: 'rust-003', - message: 'Vec created without capacity. Consider using Vec::with_capacity', - severity: this.getRuleSeverity('rust-003', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 600, - suggestedFix: { - description: 'Pre-allocate Vec capacity to avoid reallocations', - codeSnippet: 'let mut vec = Vec::with_capacity(expected_size);', - documentationUrl: 'https://docs.gasguard.dev/rules/rust-003', - }, - }))); - } - - - if (isSorobanContract) { - // Rule: soroban-001 - Inefficient storage access - if (this.isRuleEnabled('soroban-001', config)) { - const inefficientStorage = this.detectInefficientStorageAccess(code); - findings.push(...inefficientStorage.map(location => ({ - ruleId: 'soroban-001', - message: 'Multiple storage reads for the same key. Cache the value', - severity: this.getRuleSeverity('soroban-001', config), + findings.push( + ...vecWithoutCapacity.map((location) => ({ + ruleId: "rust-003", + message: + "Vec created without capacity. Consider using Vec::with_capacity", + severity: this.getRuleSeverity("rust-003", config), location: { file: filePath, ...location, }, - estimatedGasSavings: 2000, + estimatedGasSavings: 600, suggestedFix: { - description: 'Cache storage value in a local variable', - codeSnippet: 'let cached_value = env.storage().instance().get(&key);\n// Use cached_value multiple times', - documentationUrl: 'https://docs.gasguard.dev/rules/soroban-001', + description: "Pre-allocate Vec capacity to avoid reallocations", + codeSnippet: "let mut vec = Vec::with_capacity(expected_size);", + documentationUrl: "https://docs.gasguard.dev/rules/rust-003", }, - }))); + })), + ); + } + + if (isSorobanContract) { + // Rule: soroban-001 - Inefficient storage access + if (this.isRuleEnabled("soroban-001", config)) { + const inefficientStorage = this.detectInefficientStorageAccess(code); + findings.push( + ...inefficientStorage.map((location) => ({ + ruleId: "soroban-001", + message: + "Multiple storage reads for the same key. Cache the value", + severity: this.getRuleSeverity("soroban-001", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 2000, + suggestedFix: { + description: "Cache storage value in a local variable", + codeSnippet: + "let cached_value = env.storage().instance().get(&key);\n// Use cached_value multiple times", + documentationUrl: "https://docs.gasguard.dev/rules/soroban-001", + }, + })), + ); } // Rule: soroban-002 - Unbounded loops - if (this.isRuleEnabled('soroban-002', config)) { + if (this.isRuleEnabled("soroban-002", config)) { const unboundedLoops = this.detectUnboundedLoops(code); - findings.push(...unboundedLoops.map(location => ({ - ruleId: 'soroban-002', - message: 'Unbounded loop detected. This can cause CPU limit exhaustion', - severity: this.getRuleSeverity('soroban-002', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 5000, - suggestedFix: { - description: 'Add clear bounds to loops or use pagination', - documentationUrl: 'https://docs.gasguard.dev/rules/soroban-002', - }, - }))); + findings.push( + ...unboundedLoops.map((location) => ({ + ruleId: "soroban-002", + message: + "Unbounded loop detected. This can cause CPU limit exhaustion", + severity: this.getRuleSeverity("soroban-002", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 5000, + suggestedFix: { + description: "Add clear bounds to loops or use pagination", + documentationUrl: "https://docs.gasguard.dev/rules/soroban-002", + }, + })), + ); } } } catch (error) { @@ -259,15 +285,15 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { }; } - private isSorobanContract(code: string): boolean { - return code.includes('soroban_sdk') || - code.includes('#[contract]') || - code.includes('#[contractimpl]') || - code.includes('#[contracttype]'); + return ( + code.includes("soroban_sdk") || + code.includes("#[contract]") || + code.includes("#[contractimpl]") || + code.includes("#[contracttype]") + ); } - private isRuleEnabled(ruleId: string, config?: AnalyzerConfig): boolean { const cfg = config || this.config; @@ -278,14 +304,13 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { const ruleConfig = cfg.rules[ruleId]; - if (typeof ruleConfig === 'boolean') { + if (typeof ruleConfig === "boolean") { return ruleConfig; } - return (typeof ruleConfig === 'object' && ruleConfig?.enabled) ?? true; + return (typeof ruleConfig === "object" && ruleConfig?.enabled) ?? true; } - private getRuleSeverity(ruleId: string, config?: AnalyzerConfig): Severity { const cfg = config || this.config; const rule = this.getRule(ruleId); @@ -296,7 +321,7 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { if (cfg.rules && ruleId in cfg.rules) { const ruleConfig = cfg.rules[ruleId]; - if (typeof ruleConfig === 'object' && ruleConfig.severity) { + if (typeof ruleConfig === "object" && ruleConfig.severity) { return ruleConfig.severity; } } @@ -304,16 +329,18 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return rule.severity; } - private detectInefficientStringOps(code: string): Array<{ startLine: number; endLine: number }> { + private detectInefficientStringOps( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); lines.forEach((line, index) => { - if (line.includes('String::new()') && !line.includes('with_capacity')) { + if (line.includes("String::new()") && !line.includes("with_capacity")) { let hasPushStr = false; for (let i = index + 1; i < Math.min(index + 5, lines.length); i++) { const nextLine = lines[i]; - if (nextLine && nextLine.includes('push_str')) { + if (nextLine && nextLine.includes("push_str")) { hasPushStr = true; break; } @@ -331,14 +358,14 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - - private detectUnnecessaryClones(code: string): Array<{ startLine: number; endLine: number }> { + private detectUnnecessaryClones( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); - lines.forEach((line, index) => { - if (line.includes('.clone()') && !line.includes('//')) { + if (line.includes(".clone()") && !line.includes("//")) { findings.push({ startLine: index + 1, endLine: index + 1, @@ -349,13 +376,14 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - - private detectVecWithoutCapacity(code: string): Array<{ startLine: number; endLine: number }> { + private detectVecWithoutCapacity( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); lines.forEach((line, index) => { - if (line.includes('Vec::new()') && !line.includes('with_capacity')) { + if (line.includes("Vec::new()") && !line.includes("with_capacity")) { findings.push({ startLine: index + 1, endLine: index + 1, @@ -366,19 +394,22 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - - private detectInefficientStorageAccess(code: string): Array<{ startLine: number; endLine: number }> { + private detectInefficientStorageAccess( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Track storage.get() calls with the same key const storageAccess = new Map(); lines.forEach((line, index) => { - const match = line.match(/storage\(\)\.(?:instance|persistent|temporary)\(\)\.get\(&(\w+)\)/); + const match = line.match( + /storage\(\)\.(?:instance|persistent|temporary)\(\)\.get\(&(\w+)\)/, + ); if (match) { const key = match[1]; - if (typeof key === 'string') { + if (typeof key === "string") { if (!storageAccess.has(key)) { storageAccess.set(key, []); } @@ -389,7 +420,11 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { // Flag keys accessed multiple times for (const [, lineNumbers] of storageAccess.entries()) { - if (lineNumbers.length > 1 && lineNumbers[0] !== undefined && lineNumbers[lineNumbers.length - 1] !== undefined) { + if ( + lineNumbers.length > 1 && + lineNumbers[0] !== undefined && + lineNumbers[lineNumbers.length - 1] !== undefined + ) { findings.push({ startLine: lineNumbers[0]!, endLine: lineNumbers[lineNumbers.length - 1]!, @@ -400,14 +435,15 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - - private detectUnboundedLoops(code: string): Array<{ startLine: number; endLine: number }> { + private detectUnboundedLoops( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Detect while loops or for loops over storage lines.forEach((line, index) => { - if (line.includes('while') && !line.includes('//')) { + if (line.includes("while") && !line.includes("//")) { if (!line.match(/while\s+\w+\s*[<>]=?\s*\d+/)) { findings.push({ startLine: index + 1, @@ -417,7 +453,11 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { } // Check for loops over storage iterators - if (line.includes('for') && line.includes('storage()') && line.includes('iter')) { + if ( + line.includes("for") && + line.includes("storage()") && + line.includes("iter") + ) { findings.push({ startLine: index + 1, endLine: index + 1, @@ -427,4 +467,4 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } -} \ No newline at end of file +} diff --git a/libs/engine/analyzers/solidity-analyzer.ts b/libs/engine/analyzers/solidity-analyzer.ts index bcdb8f5..6309791 100644 --- a/libs/engine/analyzers/solidity-analyzer.ts +++ b/libs/engine/analyzers/solidity-analyzer.ts @@ -7,19 +7,20 @@ import { AnalyzerConfig, Finding, Severity, -} from '../core/analyzer-interface'; +} from "../core/analyzer-interface"; export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { private rules: Rule[] = [ { - id: 'sol-001', - name: 'Inefficient Loop', - description: 'Detects loops that could be optimized to reduce gas consumption', + id: "sol-001", + name: "Inefficient Loop", + description: + "Detects loops that could be optimized to reduce gas consumption", severity: Severity.HIGH, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['loops', 'gas', 'performance'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-001', + tags: ["loops", "gas", "performance"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-001", estimatedGasImpact: { min: 100, max: 5000, @@ -27,14 +28,14 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-002', - name: 'Use of storage when memory would suffice', - description: 'Detects unnecessary use of storage variables', + id: "sol-002", + name: "Use of storage when memory would suffice", + description: "Detects unnecessary use of storage variables", severity: Severity.HIGH, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['storage', 'memory', 'gas'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-002', + tags: ["storage", "memory", "gas"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-002", estimatedGasImpact: { min: 2000, max: 20000, @@ -42,14 +43,14 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-003', - name: 'Uncached array length in loop', - description: 'Array length should be cached outside of loop to save gas', + id: "sol-003", + name: "Uncached array length in loop", + description: "Array length should be cached outside of loop to save gas", severity: Severity.MEDIUM, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['loops', 'arrays', 'gas'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-003', + tags: ["loops", "arrays", "gas"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-003", estimatedGasImpact: { min: 50, max: 500, @@ -57,14 +58,14 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-004', - name: 'Use of ++ operator instead of ++i', - description: 'Using ++i is more gas efficient than i++', + id: "sol-004", + name: "Use of ++ operator instead of ++i", + description: "Using ++i is more gas efficient than i++", severity: Severity.LOW, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['operators', 'gas'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-004', + tags: ["operators", "gas"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-004", estimatedGasImpact: { min: 5, max: 20, @@ -72,55 +73,65 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-006', - name: 'Missing Reentrancy Guard', - description: 'Functions that transfer ETH or tokens should have reentrancy guards', + id: "sol-006", + name: "Missing Reentrancy Guard", + description: + "Functions that transfer ETH or tokens should have reentrancy guards", severity: Severity.CRITICAL, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'reentrancy', 'vulnerability'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-006', + tags: ["security", "reentrancy", "vulnerability"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-006", }, { - id: 'sol-007', - name: 'Insecure Fallback Function', - description: 'Fallback/default handlers should reject unknown calls or enforce strict validation', + id: "sol-007", + name: "Insecure Fallback Function", + description: + "Fallback/default handlers should reject unknown calls or enforce strict validation", severity: Severity.HIGH, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'fallback', 'receive', 'validation'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-007', + tags: ["security", "fallback", "receive", "validation"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-007", }, { - id: 'sol-009', - name: 'Missing Timelock For Sensitive Operations', - description: 'Critical operations should be scheduled and executed only after a mandatory delay', + id: "sol-009", + name: "Missing Timelock For Sensitive Operations", + description: + "Critical operations should be scheduled and executed only after a mandatory delay", severity: Severity.HIGH, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'timelock', 'governance', 'delay', 'authorization'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-009', + tags: ["security", "timelock", "governance", "delay", "authorization"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-009", }, { - id: 'sol-008', - name: 'Unsafe External Call', + id: "sol-008", + name: "Unsafe External Call", description: - 'Detects external calls with unchecked return values, dangerous delegatecall usage, or Checks-Effects-Interactions (CEI) pattern violations', + "Detects external calls with unchecked return values, dangerous delegatecall usage, or Checks-Effects-Interactions (CEI) pattern violations", severity: Severity.HIGH, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'external-calls', 'return-value', 'cei', 'delegatecall'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-008', + tags: [ + "security", + "external-calls", + "return-value", + "cei", + "delegatecall", + ], + documentationUrl: "https://docs.gasguard.dev/rules/sol-008", }, { - id: 'sol-010', - name: 'Expensive String Operations', - description: 'Detects expensive string operations like concatenation that are gas inefficient on-chain', + id: "sol-010", + name: "Expensive String Operations", + description: + "Detects expensive string operations like concatenation that are gas inefficient on-chain", severity: Severity.MEDIUM, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['strings', 'gas', 'optimization'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-010', + tags: ["strings", "gas", "optimization"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-010", estimatedGasImpact: { min: 100, max: 10000, @@ -128,14 +139,15 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-011', - name: 'Nested Loop Gas Risk', - description: 'Detects nested loops which may cause excessive gas consumption', + id: "sol-011", + name: "Nested Loop Gas Risk", + description: + "Detects nested loops which may cause excessive gas consumption", severity: Severity.HIGH, - category: 'gas-optimization', + category: "gas-optimization", enabled: true, - tags: ['loops', 'gas', 'nested'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-011', + tags: ["loops", "gas", "nested"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-011", estimatedGasImpact: { min: 500, max: 50000, @@ -143,83 +155,90 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }, }, { - id: 'sol-013', - name: 'Unsafe Timestamp Dependency', - description: 'Detects use of block.timestamp or now in control flow and warns when critical logic depends on manipulable timestamps.', + id: "sol-013", + name: "Unsafe Timestamp Dependency", + description: + "Detects use of block.timestamp or now in control flow and warns when critical logic depends on manipulable timestamps.", severity: Severity.HIGH, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'timestamp', 'blockchain', 'time-dependency'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-013', + tags: ["security", "timestamp", "blockchain", "time-dependency"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-013", }, { - id: 'sol-014', - name: 'Insecure tx.origin Authentication', - description: 'Detects tx.origin usage in authentication or authorization checks and suggests using msg.sender instead.', + id: "sol-014", + name: "Insecure tx.origin Authentication", + description: + "Detects tx.origin usage in authentication or authorization checks and suggests using msg.sender instead.", severity: Severity.HIGH, - category: 'security', + category: "security", enabled: true, - tags: ['security', 'authentication', 'tx-origin', 'msg-sender'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-014', + tags: ["security", "authentication", "tx-origin", "msg-sender"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-014", }, { - id: 'sol-012', - name: 'Missing Event Emission', - description: 'Detects state-changing functions that do not emit events', + id: "sol-012", + name: "Missing Event Emission", + description: "Detects state-changing functions that do not emit events", severity: Severity.LOW, - category: 'auditability', + category: "auditability", enabled: true, - tags: ['events', 'auditability', 'transparency'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-012', + tags: ["events", "auditability", "transparency"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-012", }, { - id: 'sol-015', - name: 'Dead Code Paths', - description: 'Identifies unreachable code paths that increase maintenance complexity and may indicate logic errors', + id: "sol-015", + name: "Dead Code Paths", + description: + "Identifies unreachable code paths that increase maintenance complexity and may indicate logic errors", severity: Severity.MEDIUM, - category: 'maintainability', + category: "maintainability", enabled: true, - tags: ['dead-code', 'maintainability', 'unreachable'], - documentationUrl: 'https://docs.gasguard.dev/rules/sol-015', + tags: ["dead-code", "maintainability", "unreachable"], + documentationUrl: "https://docs.gasguard.dev/rules/sol-015", }, ]; - - getName(): string - + + getName(): string; + getName(): string { - return 'SolidityAnalyzer'; + return "SolidityAnalyzer"; } - + getVersion(): string { - return '1.0.0'; + return "1.0.0"; } - + supportsLanguage(language: Language | string): boolean { - return language === Language.SOLIDITY || language === 'solidity' || language === 'sol'; + return ( + language === Language.SOLIDITY || + language === "solidity" || + language === "sol" + ); } - + getSupportedLanguages(): Language[] { return [Language.SOLIDITY]; } - + getRules(): Rule[] { return this.rules; } - + async analyze( code: string, filePath: string, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise { const startTime = Date.now(); const findings: Finding[] = []; const errors: Array<{ file: string; message: string; error?: Error }> = []; - + // Ensure analyzer is initialized if (!this.initialized) { await this.initialize(config); } - + // Check if file should be analyzed if (!this.shouldAnalyzeFile(filePath, config)) { return { @@ -230,264 +249,309 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, }; } - + try { // Rule: sol-003 - Uncached array length in loop - if (this.isRuleEnabled('sol-003', config)) { + if (this.isRuleEnabled("sol-003", config)) { const uncachedArrayLoops = this.detectUncachedArrayLength(code); - findings.push(...uncachedArrayLoops.map(location => ({ - ruleId: 'sol-003', - message: 'Array length is not cached in loop. Cache it to save gas.', - severity: this.getRuleSeverity('sol-003', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 200, - suggestedFix: { - description: 'Cache array length in a local variable before the loop', - codeSnippet: 'uint256 length = array.length;\nfor (uint256 i = 0; i < length; ++i) { ... }', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-003', - }, - }))); + findings.push( + ...uncachedArrayLoops.map((location) => ({ + ruleId: "sol-003", + message: + "Array length is not cached in loop. Cache it to save gas.", + severity: this.getRuleSeverity("sol-003", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 200, + suggestedFix: { + description: + "Cache array length in a local variable before the loop", + codeSnippet: + "uint256 length = array.length;\nfor (uint256 i = 0; i < length; ++i) { ... }", + documentationUrl: "https://docs.gasguard.dev/rules/sol-003", + }, + })), + ); } - + // Rule: sol-004 - Use of i++ instead of ++i - if (this.isRuleEnabled('sol-004', config)) { + if (this.isRuleEnabled("sol-004", config)) { const inefficientIncrements = this.detectInefficientIncrements(code); - findings.push(...inefficientIncrements.map(location => ({ - ruleId: 'sol-004', - message: 'Use ++i instead of i++ to save gas', - severity: this.getRuleSeverity('sol-004', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 10, - suggestedFix: { - description: 'Replace i++ with ++i', - codeSnippet: 'for (uint256 i = 0; i < length; ++i)', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-004', - }, - }))); + findings.push( + ...inefficientIncrements.map((location) => ({ + ruleId: "sol-004", + message: "Use ++i instead of i++ to save gas", + severity: this.getRuleSeverity("sol-004", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 10, + suggestedFix: { + description: "Replace i++ with ++i", + codeSnippet: "for (uint256 i = 0; i < length; ++i)", + documentationUrl: "https://docs.gasguard.dev/rules/sol-004", + }, + })), + ); } - + // Rule: sol-005 - Public function that could be external - if (this.isRuleEnabled('sol-005', config)) { - const publicFunctions = this.detectPublicFunctionsThatCouldBeExternal(code); - findings.push(...publicFunctions.map(location => ({ - ruleId: 'sol-005', - message: 'Function is public but could be external to save gas', - severity: this.getRuleSeverity('sol-005', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 300, - suggestedFix: { - description: 'Change function visibility from public to external', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-005', - }, - }))); + if (this.isRuleEnabled("sol-005", config)) { + const publicFunctions = + this.detectPublicFunctionsThatCouldBeExternal(code); + findings.push( + ...publicFunctions.map((location) => ({ + ruleId: "sol-005", + message: "Function is public but could be external to save gas", + severity: this.getRuleSeverity("sol-005", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 300, + suggestedFix: { + description: "Change function visibility from public to external", + documentationUrl: "https://docs.gasguard.dev/rules/sol-005", + }, + })), + ); } - + // Rule: sol-006 - Missing Reentrancy Guard - if (this.isRuleEnabled('sol-006', config)) { + if (this.isRuleEnabled("sol-006", config)) { const missingGuards = this.detectMissingReentrancyGuards(code); - findings.push(...missingGuards.map(location => ({ - ruleId: 'sol-006', - message: 'Function transfers ETH/tokens but lacks reentrancy guard', - severity: this.getRuleSeverity('sol-006', config), - location: { - file: filePath, - ...location, - }, - suggestedFix: { - description: 'Add reentrancy guard modifier to prevent reentrancy attacks', - codeSnippet: 'function withdraw() external nonReentrant { ... }', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-006', - }, - }))); + findings.push( + ...missingGuards.map((location) => ({ + ruleId: "sol-006", + message: "Function transfers ETH/tokens but lacks reentrancy guard", + severity: this.getRuleSeverity("sol-006", config), + location: { + file: filePath, + ...location, + }, + suggestedFix: { + description: + "Add reentrancy guard modifier to prevent reentrancy attacks", + codeSnippet: "function withdraw() external nonReentrant { ... }", + documentationUrl: "https://docs.gasguard.dev/rules/sol-006", + }, + })), + ); } // Rule: sol-007 - Insecure Fallback Function - if (this.isRuleEnabled('sol-007', config)) { + if (this.isRuleEnabled("sol-007", config)) { const insecureFallbacks = this.detectInsecureFallbackFunctions(code); - findings.push(...insecureFallbacks.map(location => ({ - ruleId: 'sol-007', - message: 'Fallback/receive handler is permissive or executes sensitive logic without strict validation', - severity: this.getRuleSeverity('sol-007', config), - location: { - file: filePath, - ...location, - }, - suggestedFix: { - description: 'Keep fallback minimal: reject unknown calls, avoid sensitive logic, and validate accepted ETH transfers', - codeSnippet: 'fallback() external payable {\n revert("Unknown function call");\n}', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-007', - }, - }))); + findings.push( + ...insecureFallbacks.map((location) => ({ + ruleId: "sol-007", + message: + "Fallback/receive handler is permissive or executes sensitive logic without strict validation", + severity: this.getRuleSeverity("sol-007", config), + location: { + file: filePath, + ...location, + }, + suggestedFix: { + description: + "Keep fallback minimal: reject unknown calls, avoid sensitive logic, and validate accepted ETH transfers", + codeSnippet: + 'fallback() external payable {\n revert("Unknown function call");\n}', + documentationUrl: "https://docs.gasguard.dev/rules/sol-007", + }, + })), + ); } // Rule: sol-009 - Missing Timelock For Sensitive Operations - if (this.isRuleEnabled('sol-009', config)) { - const missingTimelocks = this.detectMissingTimelockForSensitiveOperations(code); - findings.push(...missingTimelocks.map(location => ({ - ruleId: 'sol-009', - message: location.message, - severity: this.getRuleSeverity('sol-009', config), - location: { - file: filePath, - startLine: location.startLine, - endLine: location.endLine, - }, - suggestedFix: { - description: 'Use a timelock flow: schedule operation, enforce delay with block.timestamp checks, and execute after delay with role-based access control', - codeSnippet: 'bytes32 opId = keccak256(data);\npendingOperations[opId] = block.timestamp + TIMELOCK_DELAY;\nemit OperationScheduled(opId, pendingOperations[opId]);\n\nrequire(block.timestamp >= pendingOperations[opId], "Timelock not expired");\nexecuteOperation(opId);\nemit OperationExecuted(opId);', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-009', - }, - }))); + if (this.isRuleEnabled("sol-009", config)) { + const missingTimelocks = + this.detectMissingTimelockForSensitiveOperations(code); + findings.push( + ...missingTimelocks.map((location) => ({ + ruleId: "sol-009", + message: location.message, + severity: this.getRuleSeverity("sol-009", config), + location: { + file: filePath, + startLine: location.startLine, + endLine: location.endLine, + }, + suggestedFix: { + description: + "Use a timelock flow: schedule operation, enforce delay with block.timestamp checks, and execute after delay with role-based access control", + codeSnippet: + 'bytes32 opId = keccak256(data);\npendingOperations[opId] = block.timestamp + TIMELOCK_DELAY;\nemit OperationScheduled(opId, pendingOperations[opId]);\n\nrequire(block.timestamp >= pendingOperations[opId], "Timelock not expired");\nexecuteOperation(opId);\nemit OperationExecuted(opId);', + documentationUrl: "https://docs.gasguard.dev/rules/sol-009", + }, + })), + ); } // Rule: sol-013 - Unsafe Timestamp Dependency - if (this.isRuleEnabled('sol-013', config)) { + if (this.isRuleEnabled("sol-013", config)) { const timestampDependencies = this.detectTimestampDependencies(code); - findings.push(...timestampDependencies.map(location => ({ - ruleId: 'sol-013', - message: location.message, - severity: this.getRuleSeverity('sol-013', config), - location: { - file: filePath, - startLine: location.startLine, - endLine: location.endLine, - }, - suggestedFix: { - description: 'Avoid using block.timestamp or now for critical control flow. Use a secure timelock, on-chain delay based on block.number, or an oracle-backed time source.', - codeSnippet: 'require(block.timestamp >= unlockTime, "Too early"); // avoid relying on timestamp for security-critical decisions', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-013', - }, - }))); + findings.push( + ...timestampDependencies.map((location) => ({ + ruleId: "sol-013", + message: location.message, + severity: this.getRuleSeverity("sol-013", config), + location: { + file: filePath, + startLine: location.startLine, + endLine: location.endLine, + }, + suggestedFix: { + description: + "Avoid using block.timestamp or now for critical control flow. Use a secure timelock, on-chain delay based on block.number, or an oracle-backed time source.", + codeSnippet: + 'require(block.timestamp >= unlockTime, "Too early"); // avoid relying on timestamp for security-critical decisions', + documentationUrl: "https://docs.gasguard.dev/rules/sol-013", + }, + })), + ); } // Rule: sol-014 - Insecure tx.origin Authentication - if (this.isRuleEnabled('sol-014', config)) { + if (this.isRuleEnabled("sol-014", config)) { const txOriginFindings = this.detectTxOriginUsage(code); - findings.push(...txOriginFindings.map(location => ({ - ruleId: 'sol-014', - message: location.message, - severity: this.getRuleSeverity('sol-014', config), - location: { - file: filePath, - startLine: location.startLine, - endLine: location.endLine, - }, - suggestedFix: { - description: 'Use msg.sender for authentication and authorization checks instead of tx.origin to prevent phishing and contract-based attacks.', - codeSnippet: 'require(msg.sender == owner, "Unauthorized");', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-014', - }, - }))); + findings.push( + ...txOriginFindings.map((location) => ({ + ruleId: "sol-014", + message: location.message, + severity: this.getRuleSeverity("sol-014", config), + location: { + file: filePath, + startLine: location.startLine, + endLine: location.endLine, + }, + suggestedFix: { + description: + "Use msg.sender for authentication and authorization checks instead of tx.origin to prevent phishing and contract-based attacks.", + codeSnippet: 'require(msg.sender == owner, "Unauthorized");', + documentationUrl: "https://docs.gasguard.dev/rules/sol-014", + }, + })), + ); } - + // Rule: sol-008 - Unsafe External Calls - if (this.isRuleEnabled('sol-008', config)) { + if (this.isRuleEnabled("sol-008", config)) { const unsafeExternalCalls = this.detectUnsafeExternalCalls(code); - findings.push(...unsafeExternalCalls.map(loc => ({ - ruleId: 'sol-008', - message: loc.message, - severity: this.getRuleSeverity('sol-008', config), - location: { - file: filePath, - startLine: loc.startLine, - endLine: loc.endLine, - }, - suggestedFix: { - description: - 'Validate all external calls: capture and check return values, follow the Checks-Effects-Interactions pattern, and avoid delegatecall to untrusted contracts', - codeSnippet: - '// Capture and validate return value\n(bool success, ) = addr.call{value: amount}("");\nrequire(success, "External call failed");\n\n// Follow CEI: update state BEFORE the external call\nbalances[msg.sender] = 0;\n(bool ok, ) = msg.sender.call{value: amount}("");\nrequire(ok, "Transfer failed");', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-008', - }, - }))); + findings.push( + ...unsafeExternalCalls.map((loc) => ({ + ruleId: "sol-008", + message: loc.message, + severity: this.getRuleSeverity("sol-008", config), + location: { + file: filePath, + startLine: loc.startLine, + endLine: loc.endLine, + }, + suggestedFix: { + description: + "Validate all external calls: capture and check return values, follow the Checks-Effects-Interactions pattern, and avoid delegatecall to untrusted contracts", + codeSnippet: + '// Capture and validate return value\n(bool success, ) = addr.call{value: amount}("");\nrequire(success, "External call failed");\n\n// Follow CEI: update state BEFORE the external call\nbalances[msg.sender] = 0;\n(bool ok, ) = msg.sender.call{value: amount}("");\nrequire(ok, "Transfer failed");', + documentationUrl: "https://docs.gasguard.dev/rules/sol-008", + }, + })), + ); } - + // Rule: sol-010 - Expensive String Operations - if (this.isRuleEnabled('sol-010', config)) { + if (this.isRuleEnabled("sol-010", config)) { const expensiveStringOps = this.detectExpensiveStringOperations(code); - findings.push(...expensiveStringOps.map(location => ({ - ruleId: 'sol-010', - message: 'Expensive string operation detected. Consider using bytes instead.', - severity: this.getRuleSeverity('sol-010', config), - location: { - file: filePath, - ...location, - }, - estimatedGasSavings: 500, - suggestedFix: { - description: 'Replace string with bytes for gas efficiency, or offload string processing to the client.', - codeSnippet: 'bytes public data = "0x1234";', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-010', - }, - }))); + findings.push( + ...expensiveStringOps.map((location) => ({ + ruleId: "sol-010", + message: + "Expensive string operation detected. Consider using bytes instead.", + severity: this.getRuleSeverity("sol-010", config), + location: { + file: filePath, + ...location, + }, + estimatedGasSavings: 500, + suggestedFix: { + description: + "Replace string with bytes for gas efficiency, or offload string processing to the client.", + codeSnippet: 'bytes public data = "0x1234";', + documentationUrl: "https://docs.gasguard.dev/rules/sol-010", + }, + })), + ); } - + // Rule: sol-011 - Nested Loop Gas Risk - if (this.isRuleEnabled('sol-011', config)) { + if (this.isRuleEnabled("sol-011", config)) { const nestedLoops = this.detectNestedLoops(code); - findings.push(...nestedLoops.map(location => ({ - ruleId: 'sol-011', - message: `Nested loop detected (depth: ${location.depth}). May exceed block gas limits.`, - severity: this.getRuleSeverity('sol-011', config), - location: { - file: filePath, - startLine: location.startLine, - endLine: location.endLine, - }, - estimatedGasSavings: 2000, - suggestedFix: { - description: 'Consider refactoring to reduce loop depth or preprocess data off-chain.', - codeSnippet: '// Avoid nested loops or limit iteration bounds', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-011', - }, - }))); + findings.push( + ...nestedLoops.map((location) => ({ + ruleId: "sol-011", + message: `Nested loop detected (depth: ${location.depth}). May exceed block gas limits.`, + severity: this.getRuleSeverity("sol-011", config), + location: { + file: filePath, + startLine: location.startLine, + endLine: location.endLine, + }, + estimatedGasSavings: 2000, + suggestedFix: { + description: + "Consider refactoring to reduce loop depth or preprocess data off-chain.", + codeSnippet: "// Avoid nested loops or limit iteration bounds", + documentationUrl: "https://docs.gasguard.dev/rules/sol-011", + }, + })), + ); } - + // Rule: sol-012 - Missing Event Emission - if (this.isRuleEnabled('sol-012', config)) { + if (this.isRuleEnabled("sol-012", config)) { const missingEvents = this.detectMissingEventEmissions(code); - findings.push(...missingEvents.map(location => ({ - ruleId: 'sol-012', - message: 'State-changing function does not emit events. Consider adding events for auditability.', - severity: this.getRuleSeverity('sol-012', config), - location: { - file: filePath, - ...location, - }, - suggestedFix: { - description: 'Add event emissions for state changes.', - codeSnippet: 'event StateChanged(address indexed user, uint256 amount);\n...\nemit StateChanged(msg.sender, value);', - documentationUrl: 'https://docs.gasguard.dev/rules/sol-012', - }, - }))); + findings.push( + ...missingEvents.map((location) => ({ + ruleId: "sol-012", + message: + "State-changing function does not emit events. Consider adding events for auditability.", + severity: this.getRuleSeverity("sol-012", config), + location: { + file: filePath, + ...location, + }, + suggestedFix: { + description: "Add event emissions for state changes.", + codeSnippet: + "event StateChanged(address indexed user, uint256 amount);\n...\nemit StateChanged(msg.sender, value);", + documentationUrl: "https://docs.gasguard.dev/rules/sol-012", + }, + })), + ); } - + // Rule: sol-015 - Dead Code Paths - if (this.isRuleEnabled('sol-015', config)) { + if (this.isRuleEnabled("sol-015", config)) { const deadCodePaths = this.detectDeadCodePaths(code); - findings.push(...deadCodePaths.map(location => ({ - ruleId: 'sol-015', - message: location.message, - severity: this.getRuleSeverity('sol-015', config), - location: { - file: filePath, - startLine: location.startLine, - endLine: location.endLine, - }, - suggestedFix: { - description: location.suggestedFix, - documentationUrl: 'https://docs.gasguard.dev/rules/sol-015', - }, - }))); + findings.push( + ...deadCodePaths.map((location) => ({ + ruleId: "sol-015", + message: location.message, + severity: this.getRuleSeverity("sol-015", config), + location: { + file: filePath, + startLine: location.startLine, + endLine: location.endLine, + }, + suggestedFix: { + description: location.suggestedFix, + documentationUrl: "https://docs.gasguard.dev/rules/sol-015", + }, + })), + ); } } catch (error) { errors.push({ @@ -496,9 +560,9 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { error: error instanceof Error ? error : undefined, }); } - + const analysisTime = Date.now() - startTime; - + return { findings, filesAnalyzed: 1, @@ -509,53 +573,52 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { errors: errors.length > 0 ? errors : undefined, }; } - - + private isRuleEnabled(ruleId: string, config?: AnalyzerConfig): boolean { const cfg = config || this.config; - + if (!cfg.rules || !(ruleId in cfg.rules)) { // Use default enabled state from rule definition const rule = this.getRule(ruleId); return rule?.enabled ?? true; } - + const ruleConfig = cfg.rules[ruleId]; - - if (typeof ruleConfig === 'boolean') { + + if (typeof ruleConfig === "boolean") { return ruleConfig; } - + return ruleConfig.enabled ?? true; } - - + private getRuleSeverity(ruleId: string, config?: AnalyzerConfig): Severity { const cfg = config || this.config; const rule = this.getRule(ruleId); - + if (!rule) { return Severity.MEDIUM; } - + if (cfg.rules && ruleId in cfg.rules) { const ruleConfig = cfg.rules[ruleId]; - if (typeof ruleConfig === 'object' && ruleConfig.severity) { + if (typeof ruleConfig === "object" && ruleConfig.severity) { return ruleConfig.severity; } } - + return rule.severity; } - - - private detectUncachedArrayLength(code: string): Array<{ startLine: number; endLine: number }> { + + private detectUncachedArrayLength( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - + const lines = code.split("\n"); + // Simple regex to detect for loops with .length in condition const forLoopPattern = /for\s*\([^)]*\.length[^)]*\)/; - + lines.forEach((line, index) => { if (forLoopPattern.test(line)) { findings.push({ @@ -564,36 +627,40 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }); } }); - + return findings; } - - private detectInefficientIncrements(code: string): Array<{ startLine: number; endLine: number }> { + + private detectInefficientIncrements( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - + const lines = code.split("\n"); + // Detect i++ in for loops (but not ++i) const inefficientIncrementPattern = /\bi\+\+(?!\s*\))/; - + lines.forEach((line, index) => { - if (line.includes('for') && inefficientIncrementPattern.test(line)) { + if (line.includes("for") && inefficientIncrementPattern.test(line)) { findings.push({ startLine: index + 1, endLine: index + 1, }); } }); - + return findings; } - - private detectPublicFunctionsThatCouldBeExternal(code: string): Array<{ startLine: number; endLine: number }> { + + private detectPublicFunctionsThatCouldBeExternal( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - + const lines = code.split("\n"); + // Simple heuristic: public functions that are not called internally const publicFunctionPattern = /function\s+\w+\s*\([^)]*\)\s+public/; - + lines.forEach((line, index) => { if (publicFunctionPattern.test(line)) { findings.push({ @@ -602,19 +669,22 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }); } }); - + return findings; } - - private detectUnnecessaryStorageUsage(code: string): Array<{ startLine: number; endLine: number }> { + + private detectUnnecessaryStorageUsage( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Detect storage variables in function parameters or local variables - const storagePattern = /\b(string|bytes|uint\[\]|address\[\])\s+storage\s+\w+/; + const storagePattern = + /\b(string|bytes|uint\[\]|address\[\])\s+storage\s+\w+/; lines.forEach((line, index) => { - if (storagePattern.test(line) && !line.includes('function')) { + if (storagePattern.test(line) && !line.includes("function")) { findings.push({ startLine: index + 1, endLine: index + 1, @@ -625,9 +695,11 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - private detectMissingReentrancyGuards(code: string): Array<{ startLine: number; endLine: number }> { + private detectMissingReentrancyGuards( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Pattern to detect functions that transfer ETH or tokens const transferPatterns = [ @@ -648,7 +720,8 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { ]; // Find all function definitions - const functionPattern = /^\s*function\s+(\w+)\s*\([^}]*\)\s*(\w+)?\s*(\w+)?\s*\{/; + const functionPattern = + /^\s*function\s+(\w+)\s*\([^}]*\)\s*(\w+)?\s*(\w+)?\s*\{/; lines.forEach((line, index) => { const functionMatch = line.match(functionPattern); @@ -658,9 +731,13 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { // Check if function has reentrancy guard let hasGuard = false; - for (let i = Math.max(0, index - 5); i <= Math.min(lines.length - 1, index + 5); i++) { + for ( + let i = Math.max(0, index - 5); + i <= Math.min(lines.length - 1, index + 5); + i++ + ) { const checkLine = lines[i]; - if (guardPatterns.some(pattern => pattern.test(checkLine))) { + if (guardPatterns.some((pattern) => pattern.test(checkLine))) { hasGuard = true; break; } @@ -681,7 +758,10 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { inFunction = true; } - if (inFunction && transferPatterns.some(pattern => pattern.test(currentLine))) { + if ( + inFunction && + transferPatterns.some((pattern) => pattern.test(currentLine)) + ) { findings.push({ startLine: functionStartLine, endLine: functionStartLine, @@ -703,8 +783,12 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { private detectTimestampDependencies( code: string, ): Array<{ startLine: number; endLine: number; message: string }> { - const findings: Array<{ startLine: number; endLine: number; message: string }> = []; - const lines = code.split('\n'); + const findings: Array<{ + startLine: number; + endLine: number; + message: string; + }> = []; + const lines = code.split("\n"); let inBlockComment = false; const timestampPattern = /\b(?:block\.timestamp|now)\b/; @@ -714,17 +798,17 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { const line = lines[i]; const trimmed = line.trim(); - if (trimmed.startsWith('/*') || trimmed.startsWith('/**')) { + if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) { inBlockComment = true; } if (inBlockComment) { - if (trimmed.includes('*/')) { + if (trimmed.includes("*/")) { inBlockComment = false; } continue; } - if (trimmed.startsWith('//')) { + if (trimmed.startsWith("//")) { continue; } @@ -740,7 +824,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { startLine: i + 1, endLine: i + 1, message: - 'Unsafe reliance on block.timestamp/now detected. Block timestamps are manipulable by miners, so avoid using them for critical control flow.', + "Unsafe reliance on block.timestamp/now detected. Block timestamps are manipulable by miners, so avoid using them for critical control flow.", }); } @@ -750,8 +834,12 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { private detectTxOriginUsage( code: string, ): Array<{ startLine: number; endLine: number; message: string }> { - const findings: Array<{ startLine: number; endLine: number; message: string }> = []; - const lines = code.split('\n'); + const findings: Array<{ + startLine: number; + endLine: number; + message: string; + }> = []; + const lines = code.split("\n"); let inBlockComment = false; const txOriginPattern = /\btx\.origin\b/; @@ -760,17 +848,17 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { const line = lines[i]; const trimmed = line.trim(); - if (trimmed.startsWith('/*') || trimmed.startsWith('/**')) { + if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) { inBlockComment = true; } if (inBlockComment) { - if (trimmed.includes('*/')) { + if (trimmed.includes("*/")) { inBlockComment = false; } continue; } - if (trimmed.startsWith('//')) { + if (trimmed.startsWith("//")) { continue; } @@ -779,7 +867,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { startLine: i + 1, endLine: i + 1, message: - 'Insecure tx.origin authentication detected. Use msg.sender instead of tx.origin for authorization checks.', + "Insecure tx.origin authentication detected. Use msg.sender instead of tx.origin for authorization checks.", }); } } @@ -787,10 +875,13 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - private detectInsecureFallbackFunctions(code: string): Array<{ startLine: number; endLine: number }> { + private detectInsecureFallbackFunctions( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - const fallbackDeclarationPattern = /^\s*(fallback|receive)\s*\(\s*\)\s*[^;{]*\{/; + const lines = code.split("\n"); + const fallbackDeclarationPattern = + /^\s*(fallback|receive)\s*\(\s*\)\s*[^;{]*\{/; const hasSensitiveOperation = (body: string): boolean => { const sensitivePatterns = [ @@ -802,20 +893,27 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /\.send\s*\(/, ]; - return sensitivePatterns.some(pattern => pattern.test(body)); + return sensitivePatterns.some((pattern) => pattern.test(body)); }; const hasStateMutation = (bodyLines: string[]): boolean => { - const localDeclarationPattern = /^\s*(?:u?int(?:8|16|32|64|128|256)?|address|bool|string|bytes(?:\d+)?|bytes|mapping\s*\(|var|memory|storage)\b/; - const stateMutationPattern = /\b[A-Za-z_]\w*(?:\[[^\]]+\])?\s*(?:\+\+|--|\+=|-=|\*=|\/=|%=|=)\s*[^=]/; + const localDeclarationPattern = + /^\s*(?:u?int(?:8|16|32|64|128|256)?|address|bool|string|bytes(?:\d+)?|bytes|mapping\s*\(|var|memory|storage)\b/; + const stateMutationPattern = + /\b[A-Za-z_]\w*(?:\[[^\]]+\])?\s*(?:\+\+|--|\+=|-=|\*=|\/=|%=|=)\s*[^=]/; for (const line of bodyLines) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) { + if ( + !trimmed || + trimmed.startsWith("//") || + trimmed.startsWith("/*") || + trimmed.startsWith("*") + ) { continue; } - if (trimmed.startsWith('emit ')) { + if (trimmed.startsWith("emit ")) { continue; } @@ -832,7 +930,11 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { }; const hasExplicitReject = (body: string): boolean => { - return /\brevert\s*\(/.test(body) || /\brequire\s*\(\s*false\b/.test(body) || /\bassert\s*\(\s*false\b/.test(body); + return ( + /\brevert\s*\(/.test(body) || + /\brequire\s*\(\s*false\b/.test(body) || + /\bassert\s*\(\s*false\b/.test(body) + ); }; const hasInputValidation = (body: string): boolean => { @@ -841,24 +943,36 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /\bif\s*\([^)]*msg\.(sender|value|data)[^)]*\)\s*\{?\s*revert\s*\(/, ]; - return validationPatterns.some(pattern => pattern.test(body)); + return validationPatterns.some((pattern) => pattern.test(body)); }; const isOnlyEventsOrNoop = (bodyLines: string[]): boolean => { const executable = bodyLines - .map(line => line.trim()) - .filter(line => line && line !== '{' && line !== '}' && !line.startsWith('//') && !line.startsWith('/*') && !line.startsWith('*')); + .map((line) => line.trim()) + .filter( + (line) => + line && + line !== "{" && + line !== "}" && + !line.startsWith("//") && + !line.startsWith("/*") && + !line.startsWith("*"), + ); if (executable.length === 0) { return true; } - return executable.every(line => line.startsWith('emit ') || line === ';'); + return executable.every( + (line) => line.startsWith("emit ") || line === ";", + ); }; for (let i = 0; i < lines.length; i++) { const declarationLine = lines[i]; - const declarationMatch = declarationLine.match(fallbackDeclarationPattern); + const declarationMatch = declarationLine.match( + fallbackDeclarationPattern, + ); if (!declarationMatch) { continue; @@ -892,7 +1006,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } } - const body = bodyLines.join('\n'); + const body = bodyLines.join("\n"); const sensitive = hasSensitiveOperation(body); const mutatesState = hasStateMutation(bodyLines); const explicitReject = hasExplicitReject(body); @@ -900,10 +1014,15 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { const eventsOnly = isOnlyEventsOrNoop(bodyLines); const insecureFallback = - handlerType === 'fallback' && !explicitReject && !validatesInput && !eventsOnly; + handlerType === "fallback" && + !explicitReject && + !validatesInput && + !eventsOnly; const insecureReceive = - handlerType === 'receive' && (sensitive || mutatesState) && !validatesInput; + handlerType === "receive" && + (sensitive || mutatesState) && + !validatesInput; if (sensitive || mutatesState || insecureFallback || insecureReceive) { findings.push({ @@ -919,20 +1038,30 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { private detectMissingTimelockForSensitiveOperations( code: string, ): Array<{ startLine: number; endLine: number; message: string }> { - const findings: Array<{ startLine: number; endLine: number; message: string }> = []; - const lines = code.split('\n'); + const findings: Array<{ + startLine: number; + endLine: number; + message: string; + }> = []; + const lines = code.split("\n"); const functionDecl = /^\s*function\s+(\w+)\s*\(([^)]*)\)\s*([^\{;]*)\{/; - const sensitiveNamePattern = /(withdraw|transferOwnership|upgrade|set(?:Config|Parameter|Fee|Admin|Owner)?|grantRole|revokeRole|pause|unpause|mint|burn|treasury|emergency)/i; + const sensitiveNamePattern = + /(withdraw|transferOwnership|upgrade|set(?:Config|Parameter|Fee|Admin|Owner)?|grantRole|revokeRole|pause|unpause|mint|burn|treasury|emergency)/i; const schedulerNamePattern = /^(queue|schedule|propose)/i; const executeNamePattern = /^execute/i; const cancelNamePattern = /^cancel/i; - const contractHasTracking = /(pending|queued|operations?|timelock|eta|executeAfter|unlockTime|operationId)/i.test(code); - const contractHasSchedule = /function\s+(?:queue|schedule|propose)\w*\s*\(/i.test(code); + const contractHasTracking = + /(pending|queued|operations?|timelock|eta|executeAfter|unlockTime|operationId)/i.test( + code, + ); + const contractHasSchedule = + /function\s+(?:queue|schedule|propose)\w*\s*\(/i.test(code); const contractHasExecute = /function\s+execute\w*\s*\(/i.test(code); const contractHasCancel = /function\s+cancel\w*\s*\(/i.test(code); - const hasTimelockEvents = /event\s+\w*(Scheduled|Executed|Cancelled|Canceled)\w*\s*\(/i.test(code); + const hasTimelockEvents = + /event\s+\w*(Scheduled|Executed|Cancelled|Canceled)\w*\s*\(/i.test(code); const hasDelayEnforcement = (body: string): boolean => { const delayPatterns = [ @@ -941,11 +1070,12 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /\+\s*(TIMELOCK|DELAY|timelock|delay)/, /executeAfter|unlockTime|eta|readyAt|scheduledAt/i, ]; - return delayPatterns.some(pattern => pattern.test(body)); + return delayPatterns.some((pattern) => pattern.test(body)); }; const hasAuthorization = (signature: string, body: string): boolean => { - const signatureAuth = /(onlyOwner|onlyAdmin|onlyRole|governance|timelockAdmin)/i; + const signatureAuth = + /(onlyOwner|onlyAdmin|onlyRole|governance|timelockAdmin)/i; const bodyAuth = [ /require\s*\([^)]*msg\.sender[^)]*(owner|admin|governance)[^)]*\)/i, /hasRole\s*\(/, @@ -957,7 +1087,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return true; } - return bodyAuth.some(pattern => pattern.test(body)); + return bodyAuth.some((pattern) => pattern.test(body)); }; const hasTrackingReference = (body: string): boolean => { @@ -966,7 +1096,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /mapping\s*\(/, /delete\s+\w+/, ]; - return patterns.some(pattern => pattern.test(body)); + return patterns.some((pattern) => pattern.test(body)); }; const isStateChanging = (body: string): boolean => { @@ -979,7 +1109,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /_revokeRole\s*\(/, ]; - return stateChangePatterns.some(pattern => pattern.test(body)); + return stateChangePatterns.some((pattern) => pattern.test(body)); }; let i = 0; @@ -991,7 +1121,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } const functionName = match[1]; - const functionSignatureSuffix = match[3] || ''; + const functionSignatureSuffix = match[3] || ""; const functionStartLine = i + 1; let braceDepth = 0; @@ -1021,7 +1151,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } } - const functionBody = bodyLines.join('\n'); + const functionBody = bodyLines.join("\n"); const isViewOrPure = /\b(view|pure)\b/.test(functionSignatureSuffix); const isSensitive = sensitiveNamePattern.test(functionName); const isScheduler = schedulerNamePattern.test(functionName); @@ -1048,12 +1178,20 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } // Sensitive state-changing operations should not execute immediately. - if (isSensitive && !isScheduler && !isExecutor && !isCanceller && !isViewOrPure) { + if ( + isSensitive && + !isScheduler && + !isExecutor && + !isCanceller && + !isViewOrPure + ) { const stateChanging = isStateChanging(functionBody); if (stateChanging) { const hasDelay = hasDelayEnforcement(functionBody); - const hasTracking = hasTrackingReference(functionBody) || contractHasTracking; - const contractHasTimelockFlow = contractHasSchedule && contractHasExecute; + const hasTracking = + hasTrackingReference(functionBody) || contractHasTracking; + const contractHasTimelockFlow = + contractHasSchedule && contractHasExecute; if (!hasDelay || !hasTracking || !contractHasTimelockFlow) { findings.push({ @@ -1073,15 +1211,20 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { findings.push({ startLine: 1, endLine: 1, - message: 'Timelock flow is missing cancellation capability for queued operations', + message: + "Timelock flow is missing cancellation capability for queued operations", }); } - if ((contractHasSchedule || contractHasExecute || contractHasCancel) && !hasTimelockEvents) { + if ( + (contractHasSchedule || contractHasExecute || contractHasCancel) && + !hasTimelockEvents + ) { findings.push({ startLine: 1, endLine: 1, - message: 'Timelock operations should emit schedule/execute/cancel events for transparency', + message: + "Timelock operations should emit schedule/execute/cancel events for transparency", }); } @@ -1097,9 +1240,13 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { private detectUnsafeExternalCalls( code: string, ): Array<{ startLine: number; endLine: number; message: string }> { - const findings: Array<{ startLine: number; endLine: number; message: string }> = []; - const lines = code.split('\n'); - + const findings: Array<{ + startLine: number; + endLine: number; + message: string; + }> = []; + const lines = code.split("\n"); + // Helpers ---------------------------------------------------------------- /** Returns true when the line (or the immediately preceding line) contains a @@ -1113,7 +1260,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /** Returns true when the trimmed string is a comment or empty. */ const isCommentOrEmpty = (s: string): boolean => - !s || s.startsWith('//') || s.startsWith('*') || s.startsWith('/*'); + !s || s.startsWith("//") || s.startsWith("*") || s.startsWith("/*"); // Track multi-line block comments so we never inspect comment bodies. let inBlockComment = false; @@ -1123,12 +1270,13 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { const trimmed = lines[i].trim(); // Block-comment gating - if (trimmed.startsWith('/*') || trimmed.startsWith('/**')) inBlockComment = true; + if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) + inBlockComment = true; if (inBlockComment) { - if (trimmed.includes('*/')) inBlockComment = false; + if (trimmed.includes("*/")) inBlockComment = false; continue; } - if (trimmed.startsWith('//')) continue; + if (trimmed.startsWith("//")) continue; // 1a. delegatecall — always flag regardless of return-value capture. // delegatecall executes external bytecode inside the caller's own @@ -1139,8 +1287,8 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { startLine: i + 1, endLine: i + 1, message: - 'Use of delegatecall detected — target executes in caller storage context; ' + - 'ensure the target contract is trusted, audited, and non-upgradeable', + "Use of delegatecall detected — target executes in caller storage context; " + + "ensure the target contract is trusted, audited, and non-upgradeable", }); continue; // Don't also fire the generic unchecked-call rule for the same line. } @@ -1151,7 +1299,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { startLine: i + 1, endLine: i + 1, message: - 'External call return value not checked — always capture and validate: ' + + "External call return value not checked — always capture and validate: " + '(bool success, ) = addr.call(...); require(success, "Call failed")', }); } @@ -1215,10 +1363,10 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { const t = line.trim(); if ( isCommentOrEmpty(t) || - t.startsWith('emit ') || - t.startsWith('require(') || - t.startsWith('revert') || - t === '}' || + t.startsWith("emit ") || + t.startsWith("require(") || + t.startsWith("revert") || + t === "}" || localVarKeyword.test(line) ) { continue; @@ -1229,8 +1377,8 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { startLine: functionStartLine, endLine: functionStartLine, message: - 'Checks-Effects-Interactions (CEI) violation — state is mutated after an external call; ' + - 'move all state updates before the external interaction to prevent reentrancy', + "Checks-Effects-Interactions (CEI) violation — state is mutated after an external call; " + + "move all state updates before the external interaction to prevent reentrancy", }); ceiViolationFound = true; } @@ -1243,10 +1391,12 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - private detectExpensiveStringOperations(code: string): Array<{ startLine: number; endLine: number }> { + private detectExpensiveStringOperations( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - + const lines = code.split("\n"); + const expensiveStringPatterns = [ /string\s+concat\s*\(/, /string\.concat\s*\(/, @@ -1258,7 +1408,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { ]; lines.forEach((line, index) => { - if (expensiveStringPatterns.some(pattern => pattern.test(line))) { + if (expensiveStringPatterns.some((pattern) => pattern.test(line))) { findings.push({ startLine: index + 1, endLine: index + 1, @@ -1269,43 +1419,49 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - private detectNestedLoops(code: string): Array<{ startLine: number; endLine: number; depth: number }> { - const findings: Array<{ startLine: number; endLine: number; depth: number }> = []; - const lines = code.split('\n'); - + private detectNestedLoops( + code: string, + ): Array<{ startLine: number; endLine: number; depth: number }> { + const findings: Array<{ + startLine: number; + endLine: number; + depth: number; + }> = []; + const lines = code.split("\n"); + const loopPattern = /\b(for|while)\s*\(/; - + let braceDepth = 0; let loopDepth = 0; - let loopStack: number[] = []; + const loopStack: number[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const openBraces = (line.match(/\{/g) || []).length; const closeBraces = (line.match(/\}/g) || []).length; - + if (loopPattern.test(line)) { loopDepth++; loopStack.push(i + 1); - + if (loopDepth > 1) { findings.push({ startLine: i + 1, endLine: i + 1, - depth: loopDepth + depth: loopDepth, }); } } - + braceDepth += openBraces; braceDepth -= closeBraces; - + while (braceDepth < loopStack.length - 1) { loopStack.pop(); loopDepth--; } } - + return findings; } @@ -1326,19 +1482,20 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { suggestedFix: string; codeSnippet?: string; }> = []; - const lines = code.split('\n'); - + const lines = code.split("\n"); + const functionPattern = /^\s*function\s+(\w+)\s*\(/; const authModifierPattern = /(onlyOwner|onlyAdmin|onlyRole|auth|hasRole)/i; - const authRequirePattern = /require\s*\([^)]*(msg\.sender|hasRole|_checkRole)[^)]*\)/; - + const authRequirePattern = + /require\s*\([^)]*(msg\.sender|hasRole|_checkRole)[^)]*\)/; + for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/\b(selfdestruct|suicide)\s*\(/.test(line)) { const destructLine = i + 1; let hasAuthCheck = false; - let functionName = 'unknown'; - + let functionName = "unknown"; + for (let j = i; j >= 0; j--) { const checkLine = lines[j]; const funcMatch = checkLine.match(functionPattern); @@ -1349,19 +1506,23 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } if (authRequirePattern.test(checkLine)) hasAuthCheck = true; } - + if (!hasAuthCheck) { findings.push({ - startLine: destructLine, endLine: destructLine, + startLine: destructLine, + endLine: destructLine, message: `Unsafe selfdestruct detected in function '${functionName}' without access controls. Selfdestruct can lock assets.`, - suggestedFix: 'Add access control modifier (onlyOwner/onlyAdmin) or multi-signature requirement', + suggestedFix: + "Add access control modifier (onlyOwner/onlyAdmin) or multi-signature requirement", codeSnippet: `function ${functionName}() external onlyOwner { ... selfdestruct(payable(owner)); }`, }); } else { findings.push({ - startLine: destructLine, endLine: destructLine, + startLine: destructLine, + endLine: destructLine, message: `Selfdestruct usage in function '${functionName}'. Ensure multi-sig or timelock is in place.`, - suggestedFix: 'Consider adding a timelock delay and multi-signature requirement', + suggestedFix: + "Consider adding a timelock delay and multi-signature requirement", }); } } @@ -1369,46 +1530,49 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { return findings; } - private detectMissingEventEmissions(code: string): Array<{ startLine: number; endLine: number }> { + private detectMissingEventEmissions( + code: string, + ): Array<{ startLine: number; endLine: number }> { const findings: Array<{ startLine: number; endLine: number }> = []; - const lines = code.split('\n'); - - const functionPattern = /^\s*function\s+(\w+)\s*\([^}]*\)\s*(\w+)?\s*(\w+)?\s*\{/; - + const lines = code.split("\n"); + + const functionPattern = + /^\s*function\s+(\w+)\s*\([^}]*\)\s*(\w+)?\s*(\w+)?\s*\{/; + for (let i = 0; i < lines.length; i++) { const functionMatch = lines[i].match(functionPattern); if (functionMatch) { const functionName = functionMatch[1]; - const functionSignatureSuffix = functionMatch[2] || ''; + const functionSignatureSuffix = functionMatch[2] || ""; const isViewOrPure = /\b(view|pure)\b/.test(functionSignatureSuffix); const functionStartLine = i + 1; - + if (isViewOrPure) { continue; } - + let braceCount = 0; let hasStateMutation = false; let hasEventEmission = false; let bodyStarted = false; - + for (let j = i; j < lines.length; j++) { const currentLine = lines[j]; const openBraces = (currentLine.match(/\{/g) || []).length; const closeBraces = (currentLine.match(/\}/g) || []).length; - + braceCount += openBraces; braceCount -= closeBraces; - + if (openBraces > 0) { bodyStarted = true; } - + if (bodyStarted) { if (/^\s*emit\s/.test(currentLine)) { hasEventEmission = true; } - + const stateMutationPatterns = [ /\b\w+\s*(?:\[[^\]]+\])?\s*(?:=|\+=|-=|\*=|\/=|%=)\s*[^=]/, /\b\w+\s*\+\+/, @@ -1418,16 +1582,19 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { /payable\s*\(\s*\w+\s*\)\.transfer\s*\(/, /payable\s*\(\s*\w+\s*\)\.send\s*\(/, ]; - if (!hasStateMutation && stateMutationPatterns.some(pattern => pattern.test(currentLine))) { + if ( + !hasStateMutation && + stateMutationPatterns.some((pattern) => pattern.test(currentLine)) + ) { hasStateMutation = true; } } - + if (braceCount === 0 && bodyStarted) { break; } } - + if (hasStateMutation && !hasEventEmission) { findings.push({ startLine: functionStartLine, @@ -1436,7 +1603,7 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } } } - + return findings; } @@ -1444,53 +1611,79 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { * Detects dead code paths (sol-015): * Identifies unreachable code after unconditional exit statements */ - private detectDeadCodePaths(code: string): Array<{ startLine: number; endLine: number; message: string; suggestedFix: string }> { - const findings: Array<{ startLine: number; endLine: number; message: string; suggestedFix: string }> = []; - const lines = code.split('\n'); - + private detectDeadCodePaths(code: string): Array<{ + startLine: number; + endLine: number; + message: string; + suggestedFix: string; + }> { + const findings: Array<{ + startLine: number; + endLine: number; + message: string; + suggestedFix: string; + }> = []; + const lines = code.split("\n"); + const functionPattern = /^\s*function\s+(\w+)\s*\(/; - + for (let i = 0; i < lines.length; i++) { const funcMatch = lines[i].match(functionPattern); if (!funcMatch) continue; - + const functionName = funcMatch[1]; let braceDepth = 0; let bodyStarted = false; const bodyLines: Array<{ line: string; num: number; depth: number }> = []; - + for (let j = i; j < lines.length; j++) { const cl = lines[j]; const opens = (cl.match(/\{/g) || []).length; const closes = (cl.match(/\}/g) || []).length; if (opens > 0) bodyStarted = true; braceDepth += opens - closes; - if (bodyStarted) bodyLines.push({ line: cl, num: j + 1, depth: braceDepth }); + if (bodyStarted) + bodyLines.push({ line: cl, num: j + 1, depth: braceDepth }); if (bodyStarted && braceDepth === 0) break; } - + let exitFoundAtLine = -1; let exitDepth = -1; - + for (const bl of bodyLines) { const trimmed = bl.line.trim(); - if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed === '{' || trimmed === '}') continue; - + if ( + !trimmed || + trimmed.startsWith("//") || + trimmed.startsWith("*") || + trimmed === "{" || + trimmed === "}" + ) + continue; + const isUnconditionalExit = /^\s*return\b(?!s)/.test(bl.line) || /^\s*revert\b/.test(bl.line) || /^\s*selfdestruct\s*\(/.test(bl.line); - + if (isUnconditionalExit && bl.depth === 1) { exitFoundAtLine = bl.num; exitDepth = bl.depth; } } - + if (exitFoundAtLine > 0) { for (const bl of bodyLines) { const trimmed = bl.line.trim(); - if (bl.num > exitFoundAtLine && bl.depth <= exitDepth && !trimmed.startsWith('//') && !trimmed.startsWith('*') && trimmed !== '' && trimmed !== '{' && trimmed !== '}') { + if ( + bl.num > exitFoundAtLine && + bl.depth <= exitDepth && + !trimmed.startsWith("//") && + !trimmed.startsWith("*") && + trimmed !== "" && + trimmed !== "{" && + trimmed !== "}" + ) { findings.push({ startLine: exitFoundAtLine, endLine: bl.num, @@ -1504,4 +1697,4 @@ export class SolidityAnalyzer extends BaseAnalyzer implements Analyzer { } return findings; } -} \ No newline at end of file +} diff --git a/libs/engine/core/analyzer-interface.ts b/libs/engine/core/analyzer-interface.ts index b9206fc..94e9e95 100644 --- a/libs/engine/core/analyzer-interface.ts +++ b/libs/engine/core/analyzer-interface.ts @@ -1,13 +1,11 @@ - export enum Severity { - CRITICAL = 'critical', - HIGH = 'high', - MEDIUM = 'medium', - LOW = 'low', - INFO = 'info', + CRITICAL = "critical", + HIGH = "high", + MEDIUM = "medium", + LOW = "low", + INFO = "info", } - export interface Finding { ruleId: string; message: string; @@ -19,23 +17,19 @@ export interface Finding { startColumn?: number; endColumn?: number; }; - - + estimatedGasSavings?: number; - suggestedFix?: { - description: string; - codeSnippet?: string; - documentationUrl?: string; + suggestedFix?: { + description: string; + codeSnippet?: string; + documentationUrl?: string; }; - - + metadata?: Record; } - export interface Rule { - id: string; name: string; description: string; @@ -44,8 +38,7 @@ export interface Rule { enabled: boolean; tags?: string[]; documentationUrl?: string; - - + estimatedGasImpact?: { min: number; max: number; @@ -55,29 +48,27 @@ export interface Rule { export interface AnalyzerConfig { rules?: { - [ruleId: string]: boolean | { - enabled: boolean; - severity?: Severity; - options?: Record; - }; + [ruleId: string]: + | boolean + | { + enabled: boolean; + severity?: Severity; + options?: Record; + }; }; - - + excludePaths?: string[]; includePaths?: string[]; maxFindings?: number; options?: Record; } - export interface AnalysisResult { - findings: Finding[]; filesAnalyzed: number; analysisTime: number; analyzerVersion: string; - - + summary: { critical: number; high: number; @@ -85,10 +76,9 @@ export interface AnalysisResult { low: number; info: number; }; - - + totalEstimatedGasSavings?: number; - + /** Any errors or warnings during analysis */ errors?: Array<{ file: string; @@ -98,77 +88,69 @@ export interface AnalysisResult { } export enum Language { - SOLIDITY = 'solidity', - VYPER = 'vyper', - RUST = 'rust', - SOROBAN = 'soroban', - CAIRO = 'cairo', - MOVE = 'move', - JAVASCRIPT = 'javascript', - TYPESCRIPT = 'typescript', + SOLIDITY = "solidity", + VYPER = "vyper", + RUST = "rust", + SOROBAN = "soroban", + CAIRO = "cairo", + MOVE = "move", + JAVASCRIPT = "javascript", + TYPESCRIPT = "typescript", } - export interface Analyzer { - getName(): string; getVersion(): string; - + analyze( code: string, filePath: string, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise; - - + analyzeMultiple( files: Map, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise; - - + supportsLanguage(language: Language | string): boolean; - - + getSupportedLanguages(): Language[]; - - + getRules(): Rule[]; - - + getRule(ruleId: string): Rule | undefined; - - + validateConfig(config: AnalyzerConfig): string[]; - - + initialize(config?: AnalyzerConfig): Promise; - - + dispose(): Promise; } - export abstract class BaseAnalyzer implements Analyzer { protected config: AnalyzerConfig = {}; protected initialized = false; - + abstract getName(): string; abstract getVersion(): string; - abstract analyze(code: string, filePath: string, config?: AnalyzerConfig): Promise; + abstract analyze( + code: string, + filePath: string, + config?: AnalyzerConfig, + ): Promise; abstract supportsLanguage(language: Language | string): boolean; abstract getSupportedLanguages(): Language[]; abstract getRules(): Rule[]; - - + async analyzeMultiple( files: Map, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise { const startTime = Date.now(); const allFindings: Finding[] = []; const errors: Array<{ file: string; message: string; error?: Error }> = []; - + for (const [filePath, code] of files.entries()) { try { const result = await this.analyze(code, filePath, config); @@ -184,9 +166,9 @@ export abstract class BaseAnalyzer implements Analyzer { }); } } - + const analysisTime = Date.now() - startTime; - + return { findings: allFindings, filesAnalyzed: files.size, @@ -197,70 +179,70 @@ export abstract class BaseAnalyzer implements Analyzer { errors: errors.length > 0 ? errors : undefined, }; } - + getRule(ruleId: string): Rule | undefined { - return this.getRules().find(rule => rule.id === ruleId); + return this.getRules().find((rule) => rule.id === ruleId); } - + validateConfig(config: AnalyzerConfig): string[] { const errors: string[] = []; - + if (config.rules) { - const availableRules = new Set(this.getRules().map(r => r.id)); + const availableRules = new Set(this.getRules().map((r) => r.id)); for (const ruleId of Object.keys(config.rules)) { if (!availableRules.has(ruleId)) { errors.push(`Unknown rule: ${ruleId}`); } } } - + return errors; } - + async initialize(config?: AnalyzerConfig): Promise { if (this.initialized) { return; } - + if (config) { const errors = this.validateConfig(config); if (errors.length > 0) { - throw new Error(`Invalid configuration: ${errors.join(', ')}`); + throw new Error(`Invalid configuration: ${errors.join(", ")}`); } this.config = config; } - + this.initialized = true; } - + async dispose(): Promise { this.initialized = false; } - - - protected calculateSummary(findings: Finding[]): AnalysisResult['summary'] { + + protected calculateSummary(findings: Finding[]): AnalysisResult["summary"] { return { - critical: findings.filter(f => f.severity === Severity.CRITICAL).length, - high: findings.filter(f => f.severity === Severity.HIGH).length, - medium: findings.filter(f => f.severity === Severity.MEDIUM).length, - low: findings.filter(f => f.severity === Severity.LOW).length, - info: findings.filter(f => f.severity === Severity.INFO).length, + critical: findings.filter((f) => f.severity === Severity.CRITICAL).length, + high: findings.filter((f) => f.severity === Severity.HIGH).length, + medium: findings.filter((f) => f.severity === Severity.MEDIUM).length, + low: findings.filter((f) => f.severity === Severity.LOW).length, + info: findings.filter((f) => f.severity === Severity.INFO).length, }; } - - + protected calculateTotalGasSavings(findings: Finding[]): number | undefined { const savings = findings - .map(f => f.estimatedGasSavings || 0) + .map((f) => f.estimatedGasSavings || 0) .reduce((sum, val) => sum + val, 0); - + return savings > 0 ? savings : undefined; } - - - protected shouldAnalyzeFile(filePath: string, config?: AnalyzerConfig): boolean { + + protected shouldAnalyzeFile( + filePath: string, + config?: AnalyzerConfig, + ): boolean { const cfg = config || this.config; - + if (cfg.excludePaths) { for (const pattern of cfg.excludePaths) { if (this.matchesPattern(filePath, pattern)) { @@ -268,7 +250,7 @@ export abstract class BaseAnalyzer implements Analyzer { } } } - + if (cfg.includePaths && cfg.includePaths.length > 0) { let matches = false; for (const pattern of cfg.includePaths) { @@ -279,16 +261,16 @@ export abstract class BaseAnalyzer implements Analyzer { } return matches; } - + return true; } - + private matchesPattern(path: string, pattern: string): boolean { // Simple implementation - can be enhanced with glob patterns - if (pattern.includes('*')) { - const regex = new RegExp(pattern.replace(/\*/g, '.*')); + if (pattern.includes("*")) { + const regex = new RegExp(pattern.replace(/\*/g, ".*")); return regex.test(path); } return path.includes(pattern); } -} \ No newline at end of file +} diff --git a/libs/engine/core/analyzer-registry.ts b/libs/engine/core/analyzer-registry.ts index d57649a..dfb8c59 100644 --- a/libs/engine/core/analyzer-registry.ts +++ b/libs/engine/core/analyzer-registry.ts @@ -4,22 +4,21 @@ import { AnalysisResult, AnalyzerConfig, Rule, -} from './analyzer-interface'; - +} from "./analyzer-interface"; export class AnalyzerRegistry { private analyzers: Map = new Map(); private languageMap: Map = new Map(); - + register(analyzer: Analyzer): void { const name = analyzer.getName(); - + if (this.analyzers.has(name)) { throw new Error(`Analyzer with name "${name}" is already registered`); } - + this.analyzers.set(name, analyzer); - + // Update language map for (const language of analyzer.getSupportedLanguages()) { if (!this.languageMap.has(language)) { @@ -28,20 +27,18 @@ export class AnalyzerRegistry { this.languageMap.get(language)!.push(analyzer); } } - + async unregister(name: string): Promise { const analyzer = this.analyzers.get(name); - + if (!analyzer) { return; } - + await analyzer.dispose(); - - + this.analyzers.delete(name); - - + for (const language of analyzer.getSupportedLanguages()) { const analyzers = this.languageMap.get(language); if (analyzers) { @@ -55,84 +52,78 @@ export class AnalyzerRegistry { } } } - - + getAnalyzer(name: string): Analyzer | undefined { return this.analyzers.get(name); } - + getAnalyzersForLanguage(language: Language | string): Analyzer[] { return this.languageMap.get(language) || []; } - - + getAllAnalyzers(): Analyzer[] { return Array.from(this.analyzers.values()); } - - + getSupportedLanguages(): Array { return Array.from(this.languageMap.keys()); } - - + isLanguageSupported(language: Language | string): boolean { return this.languageMap.has(language); } - - + getAllRules(language?: Language | string): Rule[] { const analyzers = language ? this.getAnalyzersForLanguage(language) : this.getAllAnalyzers(); - + const allRules: Rule[] = []; - + for (const analyzer of analyzers) { allRules.push(...analyzer.getRules()); } - + return allRules; } - - + async initializeAll(config?: AnalyzerConfig): Promise { - const promises = Array.from(this.analyzers.values()).map(analyzer => - analyzer.initialize(config) + const promises = Array.from(this.analyzers.values()).map((analyzer) => + analyzer.initialize(config), ); - + await Promise.all(promises); } - - + async disposeAll(): Promise { - const promises = Array.from(this.analyzers.values()).map(analyzer => - analyzer.dispose() + const promises = Array.from(this.analyzers.values()).map((analyzer) => + analyzer.dispose(), ); - + await Promise.all(promises); - + this.analyzers.clear(); this.languageMap.clear(); } - - + async analyze( code: string, filePath: string, language: Language | string, config?: AnalyzerConfig, - analyzerName?: string + analyzerName?: string, ): Promise { let analyzers: Analyzer[]; - + if (analyzerName) { const analyzer = this.getAnalyzer(analyzerName); if (!analyzer) { throw new Error(`Analyzer "${analyzerName}" not found`); } if (!analyzer.supportsLanguage(language)) { - throw new Error(`Analyzer "${analyzerName}" does not support language "${language}"`); + throw new Error( + `Analyzer "${analyzerName}" does not support language "${language}"`, + ); } analyzers = [analyzer]; } else { @@ -141,99 +132,98 @@ export class AnalyzerRegistry { throw new Error(`No analyzer found for language "${language}"`); } } - - + if (analyzers.length === 1) { return analyzers[0]!.analyze(code, filePath, config); } - - + const results = await Promise.all( - analyzers.map(analyzer => analyzer.analyze(code, filePath, config)) + analyzers.map((analyzer) => analyzer.analyze(code, filePath, config)), ); return this.mergeResults(results); } - + async analyzeMultiple( files: Map, languageMap: Map, - config?: AnalyzerConfig + config?: AnalyzerConfig, ): Promise { const startTime = Date.now(); - + // Group files by language const filesByLanguage = new Map>(); - + for (const [filePath, code] of files.entries()) { const language = languageMap.get(filePath); if (!language) { continue; } - + if (!filesByLanguage.has(language)) { filesByLanguage.set(language, new Map()); } - + filesByLanguage.get(language)!.set(filePath, code); } - + // Analyze each language group const allResults: AnalysisResult[] = []; - + for (const [language, languageFiles] of filesByLanguage.entries()) { const analyzers = this.getAnalyzersForLanguage(language); - + for (const analyzer of analyzers) { const result = await analyzer.analyzeMultiple(languageFiles, config); allResults.push(result); } } - + // Merge all results const mergedResult = this.mergeResults(allResults); mergedResult.analysisTime = Date.now() - startTime; - + return mergedResult; } - + private mergeResults(results: AnalysisResult[]): AnalysisResult { if (results.length === 0) { return { findings: [], filesAnalyzed: 0, analysisTime: 0, - analyzerVersion: 'registry-1.0.0', + analyzerVersion: "registry-1.0.0", summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, }; } - + if (results.length === 1) { return results[0]!; } - - const allFindings = results.flatMap(r => r.findings); - const allErrors = results.flatMap(r => r.errors || []); + + const allFindings = results.flatMap((r) => r.findings); + const allErrors = results.flatMap((r) => r.errors || []); const totalFiles = results.reduce((sum, r) => sum + r.filesAnalyzed, 0); const totalTime = results.reduce((sum, r) => sum + r.analysisTime, 0); const totalGasSavings = results.reduce( (sum, r) => sum + (r.totalEstimatedGasSavings || 0), - 0 + 0, ); - + return { findings: allFindings, filesAnalyzed: totalFiles, analysisTime: totalTime, - analyzerVersion: 'registry-1.0.0', + analyzerVersion: "registry-1.0.0", summary: { - critical: allFindings.filter(f => f.severity === 'critical').length, - high: allFindings.filter(f => f.severity === 'high').length, - medium: allFindings.filter(f => f.severity === 'medium').length, - low: allFindings.filter(f => f.severity === 'low').length, - info: allFindings.filter(f => f.severity === 'info').length, + critical: allFindings.filter((f) => f.severity === "critical").length, + high: allFindings.filter((f) => f.severity === "high").length, + medium: allFindings.filter((f) => f.severity === "medium").length, + low: allFindings.filter((f) => f.severity === "low").length, + info: allFindings.filter((f) => f.severity === "info").length, }, - totalEstimatedGasSavings: totalGasSavings > 0 ? totalGasSavings : undefined, + totalEstimatedGasSavings: + totalGasSavings > 0 ? totalGasSavings : undefined, errors: allErrors.length > 0 ? allErrors : undefined, }; } -} \ No newline at end of file +} diff --git a/libs/engine/core/index.ts b/libs/engine/core/index.ts index 554c3e5..e69be99 100644 --- a/libs/engine/core/index.ts +++ b/libs/engine/core/index.ts @@ -1,8 +1,5 @@ - - -export * from './analyzer-interface'; -export * from './analyzer-registry'; - +export * from "./analyzer-interface"; +export * from "./analyzer-registry"; export type { Analyzer, @@ -10,15 +7,8 @@ export type { AnalysisResult, Finding, Rule, -} from './analyzer-interface'; - -export { - Language, - Severity, - BaseAnalyzer, -} from './analyzer-interface'; +} from "./analyzer-interface"; -export { - AnalyzerRegistry, -} from './analyzer-registry'; +export { Language, Severity, BaseAnalyzer } from "./analyzer-interface"; +export { AnalyzerRegistry } from "./analyzer-registry"; diff --git a/libs/monitoring/health-check.ts b/libs/monitoring/health-check.ts index 7c5214e..433a3cd 100644 --- a/libs/monitoring/health-check.ts +++ b/libs/monitoring/health-check.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios from "axios"; export interface EndpointHealth { url: string; @@ -13,12 +13,16 @@ export class HealthCheckService { try { // Basic health check: try to get a simple response or just a TCP connection check // For RPCs, we might want to call a simple method like eth_blockNumber - await axios.post(url, { - jsonrpc: '2.0', - method: 'eth_blockNumber', // Default to EVM, but can be customized - params: [], - id: 1, - }, { timeout: 5000 }); + await axios.post( + url, + { + jsonrpc: "2.0", + method: "eth_blockNumber", // Default to EVM, but can be customized + params: [], + id: 1, + }, + { timeout: 5000 }, + ); return { url, diff --git a/libs/monitoring/index.ts b/libs/monitoring/index.ts index 1a6c142..b695ce9 100644 --- a/libs/monitoring/index.ts +++ b/libs/monitoring/index.ts @@ -1 +1 @@ -export * from './health-check'; +export * from "./health-check"; diff --git a/libs/rpc/failover-strategy.ts b/libs/rpc/failover-strategy.ts index e3eed52..8c4637a 100644 --- a/libs/rpc/failover-strategy.ts +++ b/libs/rpc/failover-strategy.ts @@ -8,16 +8,18 @@ export class FailoverStrategy { private endpoints: RpcEndpoint[]; constructor(endpoints: RpcEndpoint[]) { - this.endpoints = endpoints.sort((a, b) => b.priority - a.priority || b.weight - a.weight); + this.endpoints = endpoints.sort( + (a, b) => b.priority - a.priority || b.weight - a.weight, + ); } getBestEndpoint(healthyUrls: Set): RpcEndpoint | null { - const candidates = this.endpoints.filter(e => healthyUrls.has(e.url)); + const candidates = this.endpoints.filter((e) => healthyUrls.has(e.url)); if (candidates.length === 0) return null; // Weighted random selection among top priority healthy endpoints const topPriority = candidates[0].priority; - const topCandidates = candidates.filter(e => e.priority === topPriority); + const topCandidates = candidates.filter((e) => e.priority === topPriority); const totalWeight = topCandidates.reduce((sum, e) => sum + e.weight, 0); let random = Math.random() * totalWeight; diff --git a/libs/rpc/index.ts b/libs/rpc/index.ts index 600402f..9c49942 100644 --- a/libs/rpc/index.ts +++ b/libs/rpc/index.ts @@ -1,2 +1,2 @@ -export * from './rpc-client'; -export * from './failover-strategy'; +export * from "./rpc-client"; +export * from "./failover-strategy"; diff --git a/libs/rpc/rpc-client.ts b/libs/rpc/rpc-client.ts index 9eed707..941c36e 100644 --- a/libs/rpc/rpc-client.ts +++ b/libs/rpc/rpc-client.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; -import axiosRetry from 'axios-retry'; -import { FailoverStrategy, RpcEndpoint } from './failover-strategy'; -import { HealthCheckService } from '@monitoring/health-check'; +import axios from "axios"; +import axiosRetry from "axios-retry"; +import { FailoverStrategy, RpcEndpoint } from "./failover-strategy"; +import { HealthCheckService } from "@monitoring/health-check"; export class RpcClient { private strategy: FailoverStrategy; @@ -13,8 +13,8 @@ export class RpcClient { this.endpoints = endpoints; this.strategy = new FailoverStrategy(endpoints); this.healthService = new HealthCheckService(); - this.endpoints.forEach(e => this.healthyUrls.add(e.url)); - + this.endpoints.forEach((e) => this.healthyUrls.add(e.url)); + // Initial health check this.checkHealth(); // Periodically check health @@ -35,7 +35,7 @@ export class RpcClient { async call(method: string, params: any[]): Promise { const endpoint = this.strategy.getBestEndpoint(this.healthyUrls); if (!endpoint) { - throw new Error('No healthy RPC endpoints available'); + throw new Error("No healthy RPC endpoints available"); } const client = axios.create({ @@ -47,13 +47,16 @@ export class RpcClient { retries: 3, retryDelay: axiosRetry.exponentialDelay, retryCondition: (error) => { - return axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response?.status === 429; + return ( + axiosRetry.isNetworkOrIdempotentRequestError(error) || + error.response?.status === 429 + ); }, }); try { - const response = await client.post('', { - jsonrpc: '2.0', + const response = await client.post("", { + jsonrpc: "2.0", method, params, id: Date.now(), diff --git a/libs/simulation/index.ts b/libs/simulation/index.ts index f656d73..96f796c 100644 --- a/libs/simulation/index.ts +++ b/libs/simulation/index.ts @@ -1 +1 @@ -export * from './simulation-engine'; +export * from "./simulation-engine"; diff --git a/libs/simulation/simulation-engine.ts b/libs/simulation/simulation-engine.ts index 5e094b6..6fb490f 100644 --- a/libs/simulation/simulation-engine.ts +++ b/libs/simulation/simulation-engine.ts @@ -1,4 +1,4 @@ -import { ChainAdapter, SimulationResult } from '@chains/base-adapter'; +import { ChainAdapter, SimulationResult } from "@chains/base-adapter"; export interface ComparisonReport { before: SimulationResult; @@ -11,11 +11,20 @@ export interface ComparisonReport { export class SimulationEngine { constructor(private adapter: ChainAdapter) {} - async simulateExecution(code: string, method: string, params: any[]): Promise { + async simulateExecution( + code: string, + method: string, + params: any[], + ): Promise { return this.adapter.simulate(code, method, params); } - async compareOptimizations(originalCode: string, optimizedCode: string, method: string, params: any[]): Promise { + async compareOptimizations( + originalCode: string, + optimizedCode: string, + method: string, + params: any[], + ): Promise { const [before, after] = await Promise.all([ this.simulateExecution(originalCode, method, params), this.simulateExecution(optimizedCode, method, params), @@ -29,7 +38,10 @@ export class SimulationEngine { const beforeOps = this.getOpcodeFrequencies(before.opcodes); const afterOps = this.getOpcodeFrequencies(after.opcodes); - const allOps = new Set([...Object.keys(beforeOps), ...Object.keys(afterOps)]); + const allOps = new Set([ + ...Object.keys(beforeOps), + ...Object.keys(afterOps), + ]); for (const op of allOps) { opcodeDiff[op] = (afterOps[op] || 0) - (beforeOps[op] || 0); } diff --git a/libs/testing/src/assertions.ts b/libs/testing/src/assertions.ts index 333eeb7..7b648de 100644 --- a/libs/testing/src/assertions.ts +++ b/libs/testing/src/assertions.ts @@ -2,8 +2,8 @@ * Custom assertion helpers for rule testing */ -import { Finding, Severity } from '../../../engine/core/analyzer-interface'; -import { ExpectedFinding } from './types'; +import { Finding, Severity } from "../../../engine/core/analyzer-interface"; +import { ExpectedFinding } from "./types"; export class RuleAssertions { /** @@ -12,15 +12,15 @@ export class RuleAssertions { static assertHasFinding( findings: Finding[], ruleId: string, - message?: string + message?: string, ): void { - const found = findings.find(f => f.ruleId === ruleId); - + const found = findings.find((f) => f.ruleId === ruleId); + if (!found) { - const availableRules = findings.map(f => f.ruleId).join(', '); + const availableRules = findings.map((f) => f.ruleId).join(", "); throw new Error( - message || - `Expected finding with ruleId "${ruleId}" but it was not found. Available: ${availableRules}` + message || + `Expected finding with ruleId "${ruleId}" but it was not found. Available: ${availableRules}`, ); } } @@ -31,14 +31,14 @@ export class RuleAssertions { static assertNotHasFinding( findings: Finding[], ruleId: string, - message?: string + message?: string, ): void { - const found = findings.find(f => f.ruleId === ruleId); - + const found = findings.find((f) => f.ruleId === ruleId); + if (found) { throw new Error( - message || - `Expected NOT to find ruleId "${ruleId}" but it was found at line ${found.location.startLine}` + message || + `Expected NOT to find ruleId "${ruleId}" but it was found at line ${found.location.startLine}`, ); } } @@ -49,12 +49,12 @@ export class RuleAssertions { static assertFindingCount( findings: Finding[], expectedCount: number, - message?: string + message?: string, ): void { if (findings.length !== expectedCount) { throw new Error( message || - `Expected ${expectedCount} finding(s) but got ${findings.length}` + `Expected ${expectedCount} finding(s) but got ${findings.length}`, ); } } @@ -65,17 +65,17 @@ export class RuleAssertions { static assertFindingSeverity( findings: Finding[], ruleId: string, - expectedSeverity: Severity + expectedSeverity: Severity, ): void { - const finding = findings.find(f => f.ruleId === ruleId); - + const finding = findings.find((f) => f.ruleId === ruleId); + if (!finding) { throw new Error(`Finding with ruleId "${ruleId}" not found`); } - + if (finding.severity !== expectedSeverity) { throw new Error( - `Expected severity "${expectedSeverity}" for rule "${ruleId}" but got "${finding.severity}"` + `Expected severity "${expectedSeverity}" for rule "${ruleId}" but got "${finding.severity}"`, ); } } @@ -87,20 +87,20 @@ export class RuleAssertions { findings: Finding[], ruleId: string, expectedLine: number, - tolerance: number = 0 + tolerance: number = 0, ): void { - const finding = findings.find(f => f.ruleId === ruleId); - + const finding = findings.find((f) => f.ruleId === ruleId); + if (!finding) { throw new Error(`Finding with ruleId "${ruleId}" not found`); } - + const actualLine = finding.location.startLine; const diff = Math.abs(actualLine - expectedLine); - + if (diff > tolerance) { throw new Error( - `Expected rule "${ruleId}" at line ${expectedLine} (±${tolerance}) but found at line ${actualLine}` + `Expected rule "${ruleId}" at line ${expectedLine} (±${tolerance}) but found at line ${actualLine}`, ); } } @@ -111,24 +111,24 @@ export class RuleAssertions { static assertFindingMessage( findings: Finding[], ruleId: string, - pattern: string | RegExp + pattern: string | RegExp, ): void { - const finding = findings.find(f => f.ruleId === ruleId); - + const finding = findings.find((f) => f.ruleId === ruleId); + if (!finding) { throw new Error(`Finding with ruleId "${ruleId}" not found`); } - + if (pattern instanceof RegExp) { if (!pattern.test(finding.message)) { throw new Error( - `Expected message matching ${pattern} but got "${finding.message}"` + `Expected message matching ${pattern} but got "${finding.message}"`, ); } } else { if (!finding.message.includes(pattern)) { throw new Error( - `Expected message containing "${pattern}" but got "${finding.message}"` + `Expected message containing "${pattern}" but got "${finding.message}"`, ); } } @@ -137,18 +137,15 @@ export class RuleAssertions { /** * Assert minimum gas savings */ - static assertMinGasSavings( - findings: Finding[], - minSavings: number - ): void { + static assertMinGasSavings(findings: Finding[], minSavings: number): void { const totalSavings = findings.reduce( (sum, f) => sum + (f.estimatedGasSavings || 0), - 0 + 0, ); - + if (totalSavings < minSavings) { throw new Error( - `Expected minimum gas savings of ${minSavings} but got ${totalSavings}` + `Expected minimum gas savings of ${minSavings} but got ${totalSavings}`, ); } } @@ -159,13 +156,13 @@ export class RuleAssertions { static assertSeverityCount( findings: Finding[], severity: Severity, - expectedCount: number + expectedCount: number, ): void { - const count = findings.filter(f => f.severity === severity).length; - + const count = findings.filter((f) => f.severity === severity).length; + if (count !== expectedCount) { throw new Error( - `Expected ${expectedCount} ${severity} finding(s) but got ${count}` + `Expected ${expectedCount} ${severity} finding(s) but got ${count}`, ); } } @@ -175,15 +172,15 @@ export class RuleAssertions { */ static assertMatchExpected( actual: Finding[], - expected: ExpectedFinding[] + expected: ExpectedFinding[], ): void { const errors: string[] = []; for (const exp of expected) { - const matched = actual.find(act => { + const matched = actual.find((act) => { if (act.ruleId !== exp.ruleId) return false; if (act.severity !== exp.severity) return false; - + if (exp.messagePattern) { if (exp.messagePattern instanceof RegExp) { if (!exp.messagePattern.test(act.message)) return false; @@ -191,27 +188,25 @@ export class RuleAssertions { if (!act.message.includes(exp.messagePattern)) return false; } } - + if (exp.line !== undefined) { if (Math.abs(act.location.startLine - exp.line) > 1) return false; } - + return true; }); if (!matched) { errors.push( `Missing expected finding: ${exp.ruleId} (${exp.severity})${ - exp.line ? ` at line ~${exp.line}` : '' - }` + exp.line ? ` at line ~${exp.line}` : "" + }`, ); } } if (errors.length > 0) { - throw new Error( - `Expected findings not matched:\n${errors.join('\n')}` - ); + throw new Error(`Expected findings not matched:\n${errors.join("\n")}`); } } } diff --git a/libs/testing/src/fixture-loader.ts b/libs/testing/src/fixture-loader.ts index a87581f..969c46b 100644 --- a/libs/testing/src/fixture-loader.ts +++ b/libs/testing/src/fixture-loader.ts @@ -2,21 +2,21 @@ * Fixture Loader - Load and manage test fixtures from JSON files */ -import * as fs from 'fs'; -import * as path from 'path'; -import { RuleTestFixture, RuleTestSuite } from './types'; +import * as fs from "fs"; +import * as path from "path"; +import { RuleTestFixture, RuleTestSuite } from "./types"; export class FixtureLoader { /** * Load a single fixture from JSON file */ static loadFixture(filePath: string): RuleTestFixture { - const content = fs.readFileSync(filePath, 'utf-8'); + const content = fs.readFileSync(filePath, "utf-8"); const fixture = JSON.parse(content) as RuleTestFixture; - + // Validate fixture structure this.validateFixture(fixture); - + return fixture; } @@ -25,13 +25,15 @@ export class FixtureLoader { */ static loadFixturesFromDir(dirPath: string): RuleTestFixture[] { const fixtures: RuleTestFixture[] = []; - + if (!fs.existsSync(dirPath)) { throw new Error(`Fixture directory not found: ${dirPath}`); } - const files = fs.readdirSync(dirPath).filter((f: string) => f.endsWith('.json')); - + const files = fs + .readdirSync(dirPath) + .filter((f: string) => f.endsWith(".json")); + for (const file of files) { const filePath = path.join(dirPath, file); try { @@ -41,7 +43,7 @@ export class FixtureLoader { console.warn(`Failed to load fixture ${file}:`, error); } } - + return fixtures; } @@ -49,14 +51,14 @@ export class FixtureLoader { * Load a test suite from JSON file */ static loadTestSuite(filePath: string): RuleTestSuite { - const content = fs.readFileSync(filePath, 'utf-8'); + const content = fs.readFileSync(filePath, "utf-8"); const suite = JSON.parse(content) as RuleTestSuite; - + // Validate all fixtures in suite for (const fixture of suite.fixtures) { this.validateFixture(fixture); } - + return suite; } @@ -68,8 +70,8 @@ export class FixtureLoader { name: string, description: string, input: string, - expectedFindings: RuleTestFixture['expectedFindings'], - metadata?: RuleTestFixture['metadata'] + expectedFindings: RuleTestFixture["expectedFindings"], + metadata?: RuleTestFixture["metadata"], ): RuleTestFixture { return { id, @@ -89,9 +91,9 @@ export class FixtureLoader { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - + const content = JSON.stringify(fixture, null, 2); - fs.writeFileSync(filePath, content, 'utf-8'); + fs.writeFileSync(filePath, content, "utf-8"); } /** @@ -102,9 +104,9 @@ export class FixtureLoader { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - + const content = JSON.stringify(suite, null, 2); - fs.writeFileSync(filePath, content, 'utf-8'); + fs.writeFileSync(filePath, content, "utf-8"); } /** @@ -112,16 +114,16 @@ export class FixtureLoader { */ private static validateFixture(fixture: RuleTestFixture): void { if (!fixture.id) { - throw new Error('Fixture must have an id'); + throw new Error("Fixture must have an id"); } if (!fixture.name) { - throw new Error('Fixture must have a name'); + throw new Error("Fixture must have a name"); } if (!fixture.input) { - throw new Error('Fixture must have input'); + throw new Error("Fixture must have input"); } if (!Array.isArray(fixture.expectedFindings)) { - throw new Error('Fixture must have expectedFindings array'); + throw new Error("Fixture must have expectedFindings array"); } } } diff --git a/libs/testing/src/index.ts b/libs/testing/src/index.ts index 01857f8..04e91ce 100644 --- a/libs/testing/src/index.ts +++ b/libs/testing/src/index.ts @@ -1,14 +1,14 @@ /** * GasGuard Rule Testing Framework - * + * * Provides testing utilities for rule developers including: * - Input/output fixtures * - Snapshot testing * - Rule validation helpers */ -export * from './rule-tester'; -export * from './fixture-loader'; -export * from './snapshot-manager'; -export * from './types'; -export * from './assertions'; +export * from "./rule-tester"; +export * from "./fixture-loader"; +export * from "./snapshot-manager"; +export * from "./types"; +export * from "./assertions"; diff --git a/libs/testing/src/rule-tester.ts b/libs/testing/src/rule-tester.ts index aa10942..982a4bf 100644 --- a/libs/testing/src/rule-tester.ts +++ b/libs/testing/src/rule-tester.ts @@ -2,13 +2,17 @@ * RuleTester - Core testing engine for GasGuard rules */ -import { Analyzer, AnalysisResult, Finding } from '../../../engine/core/analyzer-interface'; +import { + Analyzer, + AnalysisResult, + Finding, +} from "../../../engine/core/analyzer-interface"; import { RuleTestFixture, TestResult, ExpectedFinding, RuleTesterConfig, -} from './types'; +} from "./types"; const DEFAULT_CONFIG: RuleTesterConfig = { snapshotEnabled: false, @@ -30,27 +34,34 @@ export class RuleTester { */ async runFixture(fixture: RuleTestFixture): Promise { const startTime = Date.now(); - + try { // Run the analyzer const result = await this.analyzer.analyze( fixture.input, - `test-${fixture.id}.sol` + `test-${fixture.id}.sol`, ); const actualFindings = result.findings; - + // Match expected findings const { matched, missed, unexpected } = this.matchFindings( fixture.expectedFindings, - actualFindings + actualFindings, ); const executionTimeMs = Date.now() - startTime; const passed = missed.length === 0 && unexpected.length === 0; if (this.config.verbose) { - this.logTestResult(fixture, passed, matched, missed, unexpected, executionTimeMs); + this.logTestResult( + fixture, + passed, + matched, + missed, + unexpected, + executionTimeMs, + ); } return { @@ -64,8 +75,9 @@ export class RuleTester { }; } catch (error) { const executionTimeMs = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : String(error); - + const errorMessage = + error instanceof Error ? error.message : String(error); + return { fixture, passed: false, @@ -84,12 +96,12 @@ export class RuleTester { */ async runFixtures(fixtures: RuleTestFixture[]): Promise { const results: TestResult[] = []; - + for (const fixture of fixtures) { const result = await this.runFixture(fixture); results.push(result); } - + return results; } @@ -103,11 +115,14 @@ export class RuleTester { totalExecutionTime: number; }> { const results = await this.runFixtures(fixtures); - - const passed = results.filter(r => r.passed).length; - const failed = results.filter(r => !r.passed).length; - const totalExecutionTime = results.reduce((sum, r) => sum + r.executionTimeMs, 0); - + + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const totalExecutionTime = results.reduce( + (sum, r) => sum + r.executionTimeMs, + 0, + ); + return { results, passed, @@ -121,19 +136,19 @@ export class RuleTester { */ generateReport(results: TestResult[]): string { const total = results.length; - const passed = results.filter(r => r.passed).length; + const passed = results.filter((r) => r.passed).length; const failed = total - passed; - - let report = '\n' + '='.repeat(60) + '\n'; - report += 'RULE TEST REPORT\n'; - report += '='.repeat(60) + '\n\n'; - + + let report = "\n" + "=".repeat(60) + "\n"; + report += "RULE TEST REPORT\n"; + report += "=".repeat(60) + "\n\n"; + report += `Total: ${total} | Passed: ${passed} | Failed: ${failed}\n\n`; - + for (const result of results) { - const status = result.passed ? '✓ PASS' : '✗ FAIL'; + const status = result.passed ? "✓ PASS" : "✗ FAIL"; report += `${status} ${result.fixture.name} (${result.executionTimeMs}ms)\n`; - + if (!result.passed) { if (result.missedExpected.length > 0) { report += ` Missed ${result.missedExpected.length} expected finding(s)\n`; @@ -146,9 +161,9 @@ export class RuleTester { } } } - - report += '\n' + '='.repeat(60) + '\n'; - + + report += "\n" + "=".repeat(60) + "\n"; + return report; } @@ -157,7 +172,7 @@ export class RuleTester { */ private matchFindings( expected: ExpectedFinding[], - actual: Finding[] + actual: Finding[], ): { matched: ExpectedFinding[]; missed: ExpectedFinding[]; @@ -170,12 +185,12 @@ export class RuleTester { // Try to match each expected finding for (const exp of expected) { let found = false; - + for (let i = 0; i < actual.length; i++) { if (matchedActualIndices.has(i)) continue; - + const act = actual[i]; - + if (this.matchesExpected(act, exp)) { matched.push(exp); matchedActualIndices.add(i); @@ -183,7 +198,7 @@ export class RuleTester { break; } } - + if (!found) { missed.push(exp); } @@ -242,22 +257,22 @@ export class RuleTester { matched: ExpectedFinding[], missed: ExpectedFinding[], unexpected: Finding[], - executionTimeMs: number + executionTimeMs: number, ): void { - const status = passed ? '✓ PASS' : '✗ FAIL'; + const status = passed ? "✓ PASS" : "✗ FAIL"; console.log(`\n${status} ${fixture.name} (${executionTimeMs}ms)`); - + if (matched.length > 0) { console.log(` ✓ Matched ${matched.length} expected finding(s)`); } - + if (missed.length > 0) { console.log(` ✗ Missed ${missed.length} expected finding(s):`); for (const m of missed) { console.log(` - Rule: ${m.ruleId}, Severity: ${m.severity}`); } } - + if (unexpected.length > 0) { console.log(` ✗ Found ${unexpected.length} unexpected finding(s):`); for (const u of unexpected) { diff --git a/libs/testing/src/snapshot-manager.ts b/libs/testing/src/snapshot-manager.ts index e45fad0..9eb46c9 100644 --- a/libs/testing/src/snapshot-manager.ts +++ b/libs/testing/src/snapshot-manager.ts @@ -2,17 +2,17 @@ * Snapshot Manager - Handle snapshot testing for rules */ -import * as fs from 'fs'; -import * as path from 'path'; -import { Finding } from '../../../engine/core/analyzer-interface'; -import { TestSnapshot, ExpectedFinding } from './types'; +import * as fs from "fs"; +import * as path from "path"; +import { Finding } from "../../../engine/core/analyzer-interface"; +import { TestSnapshot, ExpectedFinding } from "./types"; export class SnapshotManager { private snapshotDir: string; - constructor(snapshotDir: string = './__snapshots__') { + constructor(snapshotDir: string = "./__snapshots__") { this.snapshotDir = snapshotDir; - + if (!fs.existsSync(this.snapshotDir)) { fs.mkdirSync(this.snapshotDir, { recursive: true }); } @@ -23,12 +23,12 @@ export class SnapshotManager { */ loadSnapshot(ruleId: string, fixtureId: string): TestSnapshot | null { const snapshotPath = this.getSnapshotPath(ruleId, fixtureId); - + if (!fs.existsSync(snapshotPath)) { return null; } - const content = fs.readFileSync(snapshotPath, 'utf-8'); + const content = fs.readFileSync(snapshotPath, "utf-8"); return JSON.parse(content) as TestSnapshot; } @@ -36,10 +36,13 @@ export class SnapshotManager { * Save snapshot for a fixture */ saveSnapshot(snapshot: TestSnapshot): void { - const snapshotPath = this.getSnapshotPath(snapshot.ruleId, snapshot.fixtureId); + const snapshotPath = this.getSnapshotPath( + snapshot.ruleId, + snapshot.fixtureId, + ); const content = JSON.stringify(snapshot, null, 2); - - fs.writeFileSync(snapshotPath, content, 'utf-8'); + + fs.writeFileSync(snapshotPath, content, "utf-8"); } /** @@ -48,16 +51,16 @@ export class SnapshotManager { compareWithSnapshot( ruleId: string, fixtureId: string, - actualFindings: Finding[] + actualFindings: Finding[], ): { matches: boolean; snapshot: TestSnapshot | null; diff?: string } { const snapshot = this.loadSnapshot(ruleId, fixtureId); - + if (!snapshot) { return { matches: false, snapshot: null }; } const matches = this.findingsMatch(snapshot.actualFindings, actualFindings); - + if (!matches) { const diff = this.generateDiff(snapshot.actualFindings, actualFindings); return { matches: false, snapshot, diff }; @@ -75,7 +78,7 @@ export class SnapshotManager { input: string, expectedFindings: ExpectedFinding[], actualFindings: Finding[], - passed: boolean + passed: boolean, ): TestSnapshot { const snapshot: TestSnapshot = { fixtureId, @@ -96,12 +99,12 @@ export class SnapshotManager { */ deleteSnapshot(ruleId: string, fixtureId: string): boolean { const snapshotPath = this.getSnapshotPath(ruleId, fixtureId); - + if (fs.existsSync(snapshotPath)) { fs.unlinkSync(snapshotPath); return true; } - + return false; } @@ -110,12 +113,12 @@ export class SnapshotManager { */ listSnapshotsForRule(ruleId: string): string[] { const ruleDir = path.join(this.snapshotDir, ruleId); - + if (!fs.existsSync(ruleDir)) { return []; } - return fs.readdirSync(ruleDir).filter((f: string) => f.endsWith('.json')); + return fs.readdirSync(ruleDir).filter((f: string) => f.endsWith(".json")); } /** @@ -123,19 +126,19 @@ export class SnapshotManager { */ clearRuleSnapshots(ruleId: string): number { const ruleDir = path.join(this.snapshotDir, ruleId); - + if (!fs.existsSync(ruleDir)) { return 0; } const files = fs.readdirSync(ruleDir); let count = 0; - + for (const file of files) { fs.unlinkSync(path.join(ruleDir, file)); count++; } - + return count; } @@ -144,7 +147,7 @@ export class SnapshotManager { */ private getSnapshotPath(ruleId: string, fixtureId: string): string { const ruleDir = path.join(this.snapshotDir, ruleId); - + if (!fs.existsSync(ruleDir)) { fs.mkdirSync(ruleDir, { recursive: true }); } @@ -177,17 +180,17 @@ export class SnapshotManager { * Generate a diff between expected and actual findings */ private generateDiff(expected: Finding[], actual: Finding[]): string { - let diff = '\nSnapshot Diff:\n'; - diff += '='.repeat(60) + '\n'; + let diff = "\nSnapshot Diff:\n"; + diff += "=".repeat(60) + "\n"; diff += `\nExpected ${expected.length} finding(s), got ${actual.length}\n\n`; if (expected.length !== actual.length) { - diff += 'Finding count mismatch\n'; + diff += "Finding count mismatch\n"; } const maxLen = Math.max(expected.length, actual.length); - + for (let i = 0; i < maxLen; i++) { const exp = expected[i]; const act = actual[i]; @@ -207,8 +210,8 @@ export class SnapshotManager { } } - diff += '='.repeat(60) + '\n'; - + diff += "=".repeat(60) + "\n"; + return diff; } } diff --git a/libs/testing/src/types.ts b/libs/testing/src/types.ts index 3465569..8adf25c 100644 --- a/libs/testing/src/types.ts +++ b/libs/testing/src/types.ts @@ -2,7 +2,11 @@ * Core types for the Rule Testing Framework */ -import { Severity, Finding, Rule } from '../../../engine/core/analyzer-interface'; +import { + Severity, + Finding, + Rule, +} from "../../../engine/core/analyzer-interface"; /** * Test fixture representing a single test case @@ -10,22 +14,22 @@ import { Severity, Finding, Rule } from '../../../engine/core/analyzer-interface export interface RuleTestFixture { /** Unique identifier for this fixture */ id: string; - + /** Human-readable name */ name: string; - + /** Description of what this test validates */ description: string; - + /** Input source code to analyze */ input: string; - + /** Expected findings (can be empty for negative tests) */ expectedFindings: ExpectedFinding[]; - + /** Optional metadata */ metadata?: { - language?: 'solidity' | 'vyper' | 'soroban' | 'rust'; + language?: "solidity" | "vyper" | "soroban" | "rust"; category?: string; tags?: string[]; [key: string]: any; @@ -38,16 +42,16 @@ export interface RuleTestFixture { export interface ExpectedFinding { /** Rule ID that should trigger */ ruleId: string; - + /** Expected severity level */ severity: Severity; - + /** Expected message (can be partial match) */ messagePattern?: string | RegExp; - + /** Expected line number (if applicable) */ line?: number; - + /** Expected gas savings estimate */ estimatedGasSavings?: number; } @@ -58,25 +62,25 @@ export interface ExpectedFinding { export interface TestResult { /** Fixture that was tested */ fixture: RuleTestFixture; - + /** Whether the test passed */ passed: boolean; - + /** Actual findings from the rule */ actualFindings: Finding[]; - + /** Matched expected findings */ matchedExpected: ExpectedFinding[]; - + /** Unmatched expected findings (false negatives) */ missedExpected: ExpectedFinding[]; - + /** Unexpected findings (false positives) */ unexpectedFindings: Finding[]; - + /** Test execution time in ms */ executionTimeMs: number; - + /** Error message if test failed */ error?: string; } @@ -87,13 +91,13 @@ export interface TestResult { export interface RuleTestSuite { /** Rule ID being tested */ ruleId: string; - + /** Suite name */ name: string; - + /** Description */ description: string; - + /** Test fixtures */ fixtures: RuleTestFixture[]; } @@ -104,13 +108,13 @@ export interface RuleTestSuite { export interface RuleTesterConfig { /** Enable snapshot testing */ snapshotEnabled?: boolean; - + /** Snapshot directory */ snapshotDir?: string; - + /** Strict mode: fail on any mismatch */ strict?: boolean; - + /** Log test execution details */ verbose?: boolean; } @@ -121,22 +125,22 @@ export interface RuleTesterConfig { export interface TestSnapshot { /** Fixture ID */ fixtureId: string; - + /** Rule ID */ ruleId: string; - + /** Snapshot timestamp */ timestamp: string; - + /** Input source code */ input: string; - + /** Expected findings */ expectedFindings: ExpectedFinding[]; - + /** Actual findings from last run */ actualFindings: Finding[]; - + /** Test result */ passed: boolean; } diff --git a/packages/cli/multi-project-scanner.ts b/packages/cli/multi-project-scanner.ts index 5e67fe9..96c05b3 100644 --- a/packages/cli/multi-project-scanner.ts +++ b/packages/cli/multi-project-scanner.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; export interface ScanResult { projectPath: string; @@ -29,7 +29,7 @@ export class MultiProjectScanner { } addProjects(paths: string[]): void { - paths.forEach(p => this.addProject(p)); + paths.forEach((p) => this.addProject(p)); } async scanAll(): Promise { @@ -55,8 +55,10 @@ export class MultiProjectScanner { } private getSourceFiles(projectPath: string): string[] { - const extensions = ['.sol', '.rs', '.vy']; - return fs.readdirSync(projectPath).filter(f => extensions.some(ext => f.endsWith(ext))); + const extensions = [".sol", ".rs", ".vy"]; + return fs + .readdirSync(projectPath) + .filter((f) => extensions.some((ext) => f.endsWith(ext))); } private aggregateResults(results: ScanResult[]): AggregatedResults { diff --git a/packages/cli/src/commands/annotate.ts b/packages/cli/src/commands/annotate.ts index 9e5fc7e..6ebbbef 100644 --- a/packages/cli/src/commands/annotate.ts +++ b/packages/cli/src/commands/annotate.ts @@ -1,12 +1,12 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import { annotateFile, Annotation } from '../../../src/reporting/annotator'; +import { Command } from "commander"; +import chalk from "chalk"; +import { annotateFile, Annotation } from "../../../src/reporting/annotator"; -export const annotateCommand = new Command('annotate') - .description('Annotate source files with inline issue comments') - .argument('', 'Source file to annotate') - .option('-o, --output ', 'Output file path (default: .annotated)') - .option('--line ', 'Line number for a demo annotation', '1') +export const annotateCommand = new Command("annotate") + .description("Annotate source files with inline issue comments") + .argument("", "Source file to annotate") + .option("-o, --output ", "Output file path (default: .annotated)") + .option("--line ", "Line number for a demo annotation", "1") .action((file: string, options) => { try { // In a real integration the annotations would come from a scan result. @@ -14,13 +14,15 @@ export const annotateCommand = new Command('annotate') const annotations: Annotation[] = [ { line: parseInt(options.line, 10), - message: 'Potential gas inefficiency detected – review this pattern.', - severity: 'warning', + message: "Potential gas inefficiency detected – review this pattern.", + severity: "warning", }, ]; const result = annotateFile(file, annotations, options.output); - console.log(chalk.green(`✓ Annotated file written to ${result.filePath}`)); + console.log( + chalk.green(`✓ Annotated file written to ${result.filePath}`), + ); } catch (err) { console.error(chalk.red(`Error annotating file: ${err}`)); process.exit(1); diff --git a/packages/cli/src/commands/ast.ts b/packages/cli/src/commands/ast.ts index 65d131e..f47dc4e 100644 --- a/packages/cli/src/commands/ast.ts +++ b/packages/cli/src/commands/ast.ts @@ -1,83 +1,96 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import fs from 'fs-extra'; -import path from 'path'; +import { Command } from "commander"; +import chalk from "chalk"; +import fs from "fs-extra"; +import path from "path"; import { parseAndSnapshot, renderTree, snapshotToJson, ASTSnapshot, -} from '../../../../libs/ast/index'; +} from "../../../../libs/ast/index"; -export const astCommand = new Command('ast') - .description('Inspect the AST of a smart contract source file') - .argument('', 'Path to a .sol, .rs, or .vy source file') - .option('--json', 'Output full snapshot as JSON instead of the tree view') - .option('--compact', 'Use compact (single-line) JSON (implies --json)') - .option('-o, --output ', 'Write output to a file instead of stdout') - .option('--stats', 'Print only the stats summary (no full tree or JSON)') - .action(async (file: string, options: { - json?: boolean; - compact?: boolean; - output?: string; - stats?: boolean; - }) => { - const absPath = path.resolve(file); +export const astCommand = new Command("ast") + .description("Inspect the AST of a smart contract source file") + .argument("", "Path to a .sol, .rs, or .vy source file") + .option("--json", "Output full snapshot as JSON instead of the tree view") + .option("--compact", "Use compact (single-line) JSON (implies --json)") + .option("-o, --output ", "Write output to a file instead of stdout") + .option("--stats", "Print only the stats summary (no full tree or JSON)") + .action( + async ( + file: string, + options: { + json?: boolean; + compact?: boolean; + output?: string; + stats?: boolean; + }, + ) => { + const absPath = path.resolve(file); - if (!(await fs.pathExists(absPath))) { - console.error(chalk.red(`✖ File not found: ${absPath}`)); - process.exit(1); - } + if (!(await fs.pathExists(absPath))) { + console.error(chalk.red(`✖ File not found: ${absPath}`)); + process.exit(1); + } - let source: string; - try { - source = await fs.readFile(absPath, 'utf-8'); - } catch (err) { - console.error(chalk.red(`✖ Failed to read file: ${(err as Error).message}`)); - process.exit(1); - } + let source: string; + try { + source = await fs.readFile(absPath, "utf-8"); + } catch (err) { + console.error( + chalk.red(`✖ Failed to read file: ${(err as Error).message}`), + ); + process.exit(1); + } - let snapshot: ASTSnapshot; - try { - snapshot = parseAndSnapshot(source, absPath); - } catch (err) { - console.error(chalk.red(`✖ Failed to parse AST: ${(err as Error).message}`)); - process.exit(1); - } + let snapshot: ASTSnapshot; + try { + snapshot = parseAndSnapshot(source, absPath); + } catch (err) { + console.error( + chalk.red(`✖ Failed to parse AST: ${(err as Error).message}`), + ); + process.exit(1); + } - // --stats only - if (options.stats) { - const s = snapshot.stats; - console.log(chalk.blue(`AST stats for ${path.basename(absPath)} [${snapshot.ast.language}]`)); - console.log(` contracts : ${s.contracts}`); - console.log(` functions : ${s.functions}`); - console.log(` state variables: ${s.state_variables}`); - console.log(` structs : ${s.structs}`); - console.log(` enums : ${s.enums}`); - return; - } + // --stats only + if (options.stats) { + const s = snapshot.stats; + console.log( + chalk.blue( + `AST stats for ${path.basename(absPath)} [${snapshot.ast.language}]`, + ), + ); + console.log(` contracts : ${s.contracts}`); + console.log(` functions : ${s.functions}`); + console.log(` state variables: ${s.state_variables}`); + console.log(` structs : ${s.structs}`); + console.log(` enums : ${s.enums}`); + return; + } - const useJson = options.json || options.compact; - const pretty = !options.compact; + const useJson = options.json || options.compact; + const pretty = !options.compact; - let output: string; - if (useJson) { - output = snapshotToJson(snapshot, pretty); - } else { - output = renderTree(snapshot); - } + let output: string; + if (useJson) { + output = snapshotToJson(snapshot, pretty); + } else { + output = renderTree(snapshot); + } - if (options.output) { - const outPath = path.resolve(options.output); - await fs.outputFile(outPath, output, 'utf-8'); - console.log(chalk.green(`✓ AST snapshot written to ${outPath}`)); - } else { - // Colour the tree output; leave JSON uncoloured for piping - if (!useJson) { - process.stdout.write(chalk.cyan(output) + '\n'); + if (options.output) { + const outPath = path.resolve(options.output); + await fs.outputFile(outPath, output, "utf-8"); + console.log(chalk.green(`✓ AST snapshot written to ${outPath}`)); } else { - process.stdout.write(output + '\n'); + // Colour the tree output; leave JSON uncoloured for piping + if (!useJson) { + process.stdout.write(chalk.cyan(output) + "\n"); + } else { + process.stdout.write(output + "\n"); + } } - } - }); + }, + ); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 42e0d57..8b1aba9 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,44 +1,51 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import fs from 'fs-extra'; -import path from 'path'; +import { Command } from "commander"; +import chalk from "chalk"; +import fs from "fs-extra"; +import path from "path"; -const showConfigCommand = new Command('show') - .description('Show current configuration') +const showConfigCommand = new Command("show") + .description("Show current configuration") .action(async () => { try { - const configPath = path.join(process.cwd(), 'gasguard.config.json'); - + const configPath = path.join(process.cwd(), "gasguard.config.json"); + if (!(await fs.pathExists(configPath))) { - console.log(chalk.yellow('No configuration file found. Run "gasguard init" to create one.')); + console.log( + chalk.yellow( + 'No configuration file found. Run "gasguard init" to create one.', + ), + ); return; } const config = await fs.readJson(configPath); - console.log(chalk.blue('Current Configuration:')); + console.log(chalk.blue("Current Configuration:")); console.log(JSON.stringify(config, null, 2)); - } catch (error) { console.error(chalk.red(`Error reading configuration: ${error}`)); process.exit(1); } }); -const setConfigCommand = new Command('set') - .description('Set a configuration value') - .argument('', 'Configuration key (e.g., scan.maxFiles)') - .argument('', 'Configuration value') +const setConfigCommand = new Command("set") + .description("Set a configuration value") + .argument("", "Configuration key (e.g., scan.maxFiles)") + .argument("", "Configuration value") .action(async (key: string, value: string) => { try { - const configPath = path.join(process.cwd(), 'gasguard.config.json'); - + const configPath = path.join(process.cwd(), "gasguard.config.json"); + if (!(await fs.pathExists(configPath))) { - console.log(chalk.yellow('No configuration file found. Run "gasguard init" to create one.')); + console.log( + chalk.yellow( + 'No configuration file found. Run "gasguard init" to create one.', + ), + ); return; } const config = await fs.readJson(configPath); - + // Parse the value (try as JSON, fallback to string) let parsedValue: any; try { @@ -48,7 +55,7 @@ const setConfigCommand = new Command('set') } // Set the nested key - const keys = key.split('.'); + const keys = key.split("."); let current: any = config; for (let i = 0; i < keys.length - 1; i++) { if (!(keys[i] in current)) { @@ -60,14 +67,13 @@ const setConfigCommand = new Command('set') await fs.writeJson(configPath, config, { spaces: 2 }); console.log(chalk.green(`✓ Set ${key} = ${JSON.stringify(parsedValue)}`)); - } catch (error) { console.error(chalk.red(`Error setting configuration: ${error}`)); process.exit(1); } }); -export const configCommand = new Command('config') - .description('Manage GasGuard configuration') +export const configCommand = new Command("config") + .description("Manage GasGuard configuration") .addCommand(showConfigCommand) .addCommand(setConfigCommand); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0e43fbf..f6a43e6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,49 +1,54 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import fs from 'fs-extra'; -import path from 'path'; +import { Command } from "commander"; +import chalk from "chalk"; +import fs from "fs-extra"; +import path from "path"; -export const initCommand = new Command('init') - .description('Initialize GasGuard configuration in the current directory') - .option('-f, --force', 'Overwrite existing configuration') +export const initCommand = new Command("init") + .description("Initialize GasGuard configuration in the current directory") + .option("-f, --force", "Overwrite existing configuration") .action(async (options) => { try { - const configPath = path.join(process.cwd(), 'gasguard.config.json'); - + const configPath = path.join(process.cwd(), "gasguard.config.json"); + // Check if config already exists - if (await fs.pathExists(configPath) && !options.force) { - console.log(chalk.yellow('Configuration file already exists. Use --force to overwrite.')); + if ((await fs.pathExists(configPath)) && !options.force) { + console.log( + chalk.yellow( + "Configuration file already exists. Use --force to overwrite.", + ), + ); return; } const defaultConfig = { - version: '1.0.0', + version: "1.0.0", scan: { - include: ['**/*.sol', '**/*.vy', '**/*.rs'], - exclude: ['node_modules/**', 'dist/**', 'build/**', 'target/**'], - maxFiles: 1000 + include: ["**/*.sol", "**/*.vy", "**/*.rs"], + exclude: ["node_modules/**", "dist/**", "build/**", "target/**"], + maxFiles: 1000, }, rules: { - enabled: ['SOL-001', 'SOL-002', 'SOL-003', 'VY-001', 'VY-002'], - severity: ['high', 'medium', 'low'] + enabled: ["SOL-001", "SOL-002", "SOL-003", "VY-001", "VY-002"], + severity: ["high", "medium", "low"], }, output: { - format: 'both', + format: "both", summary: true, fixPreview: false, - confidenceThreshold: 0.7 + confidenceThreshold: 0.7, }, autoFix: { enabled: false, safeOnly: true, - backup: true - } + backup: true, + }, }; await fs.writeJson(configPath, defaultConfig, { spaces: 2 }); - console.log(chalk.green('✓ GasGuard configuration initialized successfully.')); + console.log( + chalk.green("✓ GasGuard configuration initialized successfully."), + ); console.log(chalk.gray(`Configuration file: ${configPath}`)); - } catch (error) { console.error(chalk.red(`Error initializing configuration: ${error}`)); process.exit(1); diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts index 2e606f5..d27d577 100644 --- a/packages/cli/src/commands/scan.ts +++ b/packages/cli/src/commands/scan.ts @@ -1,32 +1,42 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import fs from 'fs-extra'; -import path from 'path'; -import { generateJsonReport } from '../../reporting/json-reporter'; -import { generateSarifReport } from '../../reporting/sarif-reporter'; -import { printSummary } from '../../reporting/summary-printer'; -import { ScanWatcher } from '../../../../src/analysis/watch/watcher'; - - -export const scanCommand = new Command('scan') - .description('Scan smart contracts for gas optimization opportunities') - .argument('[path]', 'Path to scan (default: current directory)', '.') - .option('-o, --output ', 'Output file for JSON report') - .option('-f, --format ', 'Output format (json, sarif, text, both)', 'both') - .option('--no-summary', 'Disable printable summary') - .option('--fix-preview', 'Show fix previews for violations') - .option('-w, --watch', 'Watch for file changes and re-run scans automatically') - .option('--confidence ', 'Minimum confidence threshold (0.0-1.0)', '0.7') +import { Command } from "commander"; +import chalk from "chalk"; +import fs from "fs-extra"; +import path from "path"; +import { generateJsonReport } from "../../reporting/json-reporter"; +import { generateSarifReport } from "../../reporting/sarif-reporter"; +import { printSummary } from "../../reporting/summary-printer"; +import { ScanWatcher } from "../../../../src/analysis/watch/watcher"; + +export const scanCommand = new Command("scan") + .description("Scan smart contracts for gas optimization opportunities") + .argument("[path]", "Path to scan (default: current directory)", ".") + .option("-o, --output ", "Output file for JSON report") + .option( + "-f, --format ", + "Output format (json, sarif, text, both)", + "both", + ) + .option("--no-summary", "Disable printable summary") + .option("--fix-preview", "Show fix previews for violations") + .option( + "-w, --watch", + "Watch for file changes and re-run scans automatically", + ) + .option( + "--confidence ", + "Minimum confidence threshold (0.0-1.0)", + "0.7", + ) .action(async (scanPath: string, options) => { try { const runScan = async () => { console.log(chalk.blue(`\n🔍 Scanning ${scanPath}...`)); - + // Collect scannable files const files = await collectScannableFiles(scanPath); - + if (files.length === 0) { - console.log(chalk.yellow('No scannable files found.')); + console.log(chalk.yellow("No scannable files found.")); return; } @@ -36,19 +46,25 @@ export const scanCommand = new Command('scan') const scanResults = await simulateScan(files); // Generate reports - if (options.format === 'json' || options.format === 'both') { - const outputPath = options.output || path.join(process.cwd(), 'gasguard-report.json'); + if (options.format === "json" || options.format === "both") { + const outputPath = + options.output || path.join(process.cwd(), "gasguard-report.json"); await generateJsonReport(scanResults, outputPath); console.log(chalk.green(`✓ JSON report saved to ${outputPath}`)); } - if (options.format === 'sarif') { - const outputPath = options.output || path.join(process.cwd(), 'gasguard-report.sarif.json'); + if (options.format === "sarif") { + const outputPath = + options.output || + path.join(process.cwd(), "gasguard-report.sarif.json"); await generateSarifReport(scanResults, outputPath); console.log(chalk.green(`✓ SARIF report saved to ${outputPath}`)); } - if (options.summary !== false && (options.format === 'text' || options.format === 'both')) { + if ( + options.summary !== false && + (options.format === "text" || options.format === "both") + ) { printSummary(scanResults, options); } }; @@ -58,23 +74,26 @@ export const scanCommand = new Command('scan') // Setup Watch Mode if requested if (options.watch) { - console.log(chalk.cyan(`\n👀 Watch mode enabled. Listening for changes in ${scanPath}...`)); + console.log( + chalk.cyan( + `\n👀 Watch mode enabled. Listening for changes in ${scanPath}...`, + ), + ); const watcher = new ScanWatcher(scanPath, { - ignored: (p) => p.includes('node_modules') || p.includes('.git') + ignored: (p) => p.includes("node_modules") || p.includes(".git"), }); - + watcher.watch(async (filePath) => { console.log(chalk.cyan(`\n[File Changed] ${filePath}`)); await runScan(); }); - + // Keep the process alive - process.on('SIGINT', () => { + process.on("SIGINT", () => { watcher.stop(); process.exit(0); }); } - } catch (error) { console.error(chalk.red(`Error during scan: ${error}`)); process.exit(1); @@ -83,17 +102,21 @@ export const scanCommand = new Command('scan') async function collectScannableFiles(dirPath: string): Promise { const files: string[] = []; - const extensions = ['.sol', '.vy', '.rs']; + const extensions = [".sol", ".vy", ".rs"]; async function walk(currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); - + for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); - + if (entry.isDirectory()) { // Skip node_modules and .git - if (!['node_modules', '.git', 'target', 'dist', 'build'].includes(entry.name)) { + if ( + !["node_modules", ".git", "target", "dist", "build"].includes( + entry.name, + ) + ) { await walk(fullPath); } } else if (entry.isFile()) { @@ -113,7 +136,7 @@ async function simulateScan(files: string[]): Promise { // This is a placeholder - in real implementation, this would use the actual scanner const results = { timestamp: new Date().toISOString(), - scanPath: files[0] || '.', + scanPath: files[0] || ".", totalFiles: files.length, scannedFiles: files.length, findings: [], @@ -124,11 +147,11 @@ async function simulateScan(files: string[]): Promise { high: 0, medium: 0, low: 0, - info: 0 + info: 0, }, byRule: {}, - totalGasSavings: 0 - } + totalGasSavings: 0, + }, }; // Simulate some findings for demonstration @@ -136,18 +159,18 @@ async function simulateScan(files: string[]): Promise { results.findings.push({ file: files[0], line: 10, - ruleId: 'SOL-001', - ruleName: 'string-to-bytes32', - severity: 'high', - message: 'Use bytes32 instead of string for fixed-length data', - suggestion: 'Replace string with bytes32 to save gas', + ruleId: "SOL-001", + ruleName: "string-to-bytes32", + severity: "high", + message: "Use bytes32 instead of string for fixed-length data", + suggestion: "Replace string with bytes32 to save gas", gasSavings: 5000, - confidence: 0.9 + confidence: 0.9, }); results.summary.totalViolations = 1; results.summary.bySeverity.high = 1; - results.summary.byRule['SOL-001'] = 1; + results.summary.byRule["SOL-001"] = 1; results.summary.totalGasSavings = 5000; } diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts index c9d35fa..254fedf 100644 --- a/packages/cli/src/commands/version.ts +++ b/packages/cli/src/commands/version.ts @@ -1,22 +1,25 @@ -import { Command } from 'commander'; -import chalk from 'chalk'; -import { readFileSync } from 'fs'; -import path from 'path'; +import { Command } from "commander"; +import chalk from "chalk"; +import { readFileSync } from "fs"; +import path from "path"; -export const versionCommand = new Command('version') - .description('Show version information') +export const versionCommand = new Command("version") + .description("Show version information") .action(() => { try { - const packagePath = path.join(__dirname, '../../package.json'); - const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8')); - - console.log(chalk.blue('GasGuard CLI')); + const packagePath = path.join(__dirname, "../../package.json"); + const packageJson = JSON.parse(readFileSync(packagePath, "utf-8")); + + console.log(chalk.blue("GasGuard CLI")); console.log(chalk.gray(`Version: ${packageJson.version}`)); - console.log(chalk.gray('Gas optimization analysis tool for smart contracts')); - + console.log( + chalk.gray("Gas optimization analysis tool for smart contracts"), + ); } catch (error) { - console.log(chalk.blue('GasGuard CLI')); - console.log(chalk.gray('Version: 1.0.0')); - console.log(chalk.gray('Gas optimization analysis tool for smart contracts')); + console.log(chalk.blue("GasGuard CLI")); + console.log(chalk.gray("Version: 1.0.0")); + console.log( + chalk.gray("Gas optimization analysis tool for smart contracts"), + ); } }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7e1f8b0..2dae888 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,22 +1,22 @@ #!/usr/bin/env node -import { Command } from 'commander'; -import chalk from 'chalk'; -import { scanCommand } from './commands/scan'; -import { initCommand } from './commands/init'; -import { configCommand } from './commands/config'; -import { versionCommand } from './commands/version'; +import { Command } from "commander"; +import chalk from "chalk"; +import { scanCommand } from "./commands/scan"; +import { initCommand } from "./commands/init"; +import { configCommand } from "./commands/config"; +import { versionCommand } from "./commands/version"; const program = new Command(); // Configure CLI program - .name('gasguard') - .description('GasGuard CLI - Smart contract gas optimization analysis tool') - .version('1.0.0') - .option('-v, --verbose', 'Enable verbose output') - .option('--debug', 'Enable debug mode for troubleshooting') - .option('--no-color', 'Disable colored output'); + .name("gasguard") + .description("GasGuard CLI - Smart contract gas optimization analysis tool") + .version("1.0.0") + .option("-v, --verbose", "Enable verbose output") + .option("--debug", "Enable debug mode for troubleshooting") + .option("--no-color", "Disable colored output"); // Global error handling program.configureOutput({ @@ -31,9 +31,9 @@ program.addCommand(configCommand); program.addCommand(versionCommand); // Handle unknown commands -program.on('command:*', () => { - console.error(chalk.red(`Invalid command: ${program.args.join(' ')}`)); - console.log(chalk.yellow('See --help for a list of available commands.')); +program.on("command:*", () => { + console.error(chalk.red(`Invalid command: ${program.args.join(" ")}`)); + console.log(chalk.yellow("See --help for a list of available commands.")); process.exit(1); }); diff --git a/packages/cli/src/reporting/json-reporter.ts b/packages/cli/src/reporting/json-reporter.ts index c6869a2..ea69811 100644 --- a/packages/cli/src/reporting/json-reporter.ts +++ b/packages/cli/src/reporting/json-reporter.ts @@ -1,5 +1,5 @@ -import fs from 'fs-extra'; -import path from 'path'; +import fs from "fs-extra"; +import path from "path"; export interface ScanResult { timestamp: string; @@ -35,11 +35,14 @@ export interface Summary { totalGasSavings: number; } -export async function generateJsonReport(results: ScanResult, outputPath: string): Promise { +export async function generateJsonReport( + results: ScanResult, + outputPath: string, +): Promise { const report = { metadata: { - version: '1.0.0', - tool: 'GasGuard CLI', + version: "1.0.0", + tool: "GasGuard CLI", timestamp: results.timestamp, scanPath: results.scanPath, }, @@ -51,7 +54,7 @@ export async function generateJsonReport(results: ScanResult, outputPath: string bySeverity: results.summary.bySeverity, byRule: results.summary.byRule, }, - findings: results.findings.map(finding => ({ + findings: results.findings.map((finding) => ({ ...finding, category: categorizeFinding(finding), })), @@ -66,30 +69,42 @@ export async function generateJsonReport(results: ScanResult, outputPath: string } function categorizeFinding(finding: Finding): string { - if (finding.ruleId.startsWith('SOL-')) return 'solidity'; - if (finding.ruleId.startsWith('VY-')) return 'vyper'; - if (finding.ruleId.startsWith('RS-')) return 'rust'; - if (finding.ruleId.startsWith('SOR-')) return 'soroban'; - return 'general'; + if (finding.ruleId.startsWith("SOL-")) return "solidity"; + if (finding.ruleId.startsWith("VY-")) return "vyper"; + if (finding.ruleId.startsWith("RS-")) return "rust"; + if (finding.ruleId.startsWith("SOR-")) return "soroban"; + return "general"; } -export async function generateCsvReport(results: ScanResult, outputPath: string): Promise { - const headers = ['File', 'Line', 'Rule ID', 'Rule Name', 'Severity', 'Message', 'Gas Savings', 'Confidence']; - const rows = results.findings.map(f => [ +export async function generateCsvReport( + results: ScanResult, + outputPath: string, +): Promise { + const headers = [ + "File", + "Line", + "Rule ID", + "Rule Name", + "Severity", + "Message", + "Gas Savings", + "Confidence", + ]; + const rows = results.findings.map((f) => [ f.file, f.line.toString(), f.ruleId, f.ruleName, f.severity, f.message, - f.gasSavings?.toString() || 'N/A', - f.confidence?.toFixed(2) || 'N/A', + f.gasSavings?.toString() || "N/A", + f.confidence?.toFixed(2) || "N/A", ]); const csvContent = [ - headers.join(','), - ...rows.map(row => row.map(cell => `"${cell}"`).join(',')), - ].join('\n'); + headers.join(","), + ...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")), + ].join("\n"); const outputDir = path.dirname(outputPath); await fs.ensureDir(outputDir); diff --git a/packages/cli/src/reporting/sarif-reporter.spec.ts b/packages/cli/src/reporting/sarif-reporter.spec.ts index 116c445..c5c4399 100644 --- a/packages/cli/src/reporting/sarif-reporter.spec.ts +++ b/packages/cli/src/reporting/sarif-reporter.spec.ts @@ -1,23 +1,23 @@ -import { generateSarifReport } from './sarif-reporter'; -import { ScanResult } from './sarif-reporter'; -import * as fs from 'fs-extra'; -import * as path from 'path'; +import { generateSarifReport } from "./sarif-reporter"; +import { ScanResult } from "./sarif-reporter"; +import * as fs from "fs-extra"; +import * as path from "path"; -describe('SARIF Reporter', () => { +describe("SARIF Reporter", () => { const mockScanResult: ScanResult = { - timestamp: '2024-01-01T00:00:00.000Z', - scanPath: '/test/path', + timestamp: "2024-01-01T00:00:00.000Z", + scanPath: "/test/path", totalFiles: 1, scannedFiles: 1, findings: [ { - file: '/test/path/contract.sol', + file: "/test/path/contract.sol", line: 10, - ruleId: 'SOL-001', - ruleName: 'string-to-bytes32', - severity: 'high', - message: 'Use bytes32 instead of string for fixed-length data', - suggestion: 'Replace string with bytes32 to save gas', + ruleId: "SOL-001", + ruleName: "string-to-bytes32", + severity: "high", + message: "Use bytes32 instead of string for fixed-length data", + suggestion: "Replace string with bytes32 to save gas", gasSavings: 5000, confidence: 0.9, }, @@ -32,58 +32,64 @@ describe('SARIF Reporter', () => { info: 0, }, byRule: { - 'SOL-001': 1, + "SOL-001": 1, }, totalGasSavings: 5000, }, }; - it('should generate a valid SARIF report', async () => { - const outputPath = path.join(__dirname, 'test-output.sarif.json'); - + it("should generate a valid SARIF report", async () => { + const outputPath = path.join(__dirname, "test-output.sarif.json"); + await generateSarifReport(mockScanResult, outputPath); - + // Verify file was created const fileExists = await fs.pathExists(outputPath); expect(fileExists).toBe(true); - + // Verify SARIF structure const report = await fs.readJson(outputPath); - expect(report.version).toBe('2.1.0'); - expect(report.$schema).toBe('https://json.schemastore.org/sarif-2.1.0.json'); + expect(report.version).toBe("2.1.0"); + expect(report.$schema).toBe( + "https://json.schemastore.org/sarif-2.1.0.json", + ); expect(report.runs).toBeDefined(); expect(report.runs.length).toBeGreaterThan(0); - expect(report.runs[0].tool.driver.name).toBe('GasGuard'); + expect(report.runs[0].tool.driver.name).toBe("GasGuard"); expect(report.runs[0].results).toBeDefined(); expect(report.runs[0].results.length).toBe(1); - + // Verify result structure const result = report.runs[0].results[0]; - expect(result.ruleId).toBe('SOL-001'); - expect(result.level).toBe('error'); - expect(result.message.text).toBe('Use bytes32 instead of string for fixed-length data'); + expect(result.ruleId).toBe("SOL-001"); + expect(result.level).toBe("error"); + expect(result.message.text).toBe( + "Use bytes32 instead of string for fixed-length data", + ); expect(result.locations).toBeDefined(); expect(result.locations.length).toBe(1); - expect(result.locations[0].physicalLocation.artifactLocation.uri).toBe('/test/path/contract.sol'); + expect(result.locations[0].physicalLocation.artifactLocation.uri).toBe( + "/test/path/contract.sol", + ); expect(result.locations[0].physicalLocation.region.startLine).toBe(10); - + // Verify rule structure const rules = report.runs[0].tool.driver.rules; expect(rules).toBeDefined(); expect(rules.length).toBeGreaterThan(0); - expect(rules[0].id).toBe('SOL-001'); + expect(rules[0].id).toBe("SOL-001"); expect(rules[0].shortDescription.text).toBeDefined(); expect(rules[0].properties).toBeDefined(); - expect(rules[0].properties.category).toBe('solidity'); - + expect(rules[0].properties.category).toBe("solidity"); + // Cleanup await fs.remove(outputPath); }); - it('should handle empty findings', async () => { + it("should handle empty findings", async () => { const emptyResult: ScanResult = { - timestamp: '2024-01-01T00:00:00.000Z', - scanPath: '/test/path', + timestamp: "2024-01-01T00:00:00.000Z", + scanPath: "/test/path", totalFiles: 0, scannedFiles: 0, findings: [], @@ -100,48 +106,48 @@ describe('SARIF Reporter', () => { totalGasSavings: 0, }, }; - - const outputPath = path.join(__dirname, 'test-empty.sarif.json'); - + + const outputPath = path.join(__dirname, "test-empty.sarif.json"); + await generateSarifReport(emptyResult, outputPath); - + const report = await fs.readJson(outputPath); expect(report.runs[0].results).toEqual([]); - + await fs.remove(outputPath); }); - it('should handle different severity levels', async () => { + it("should handle different severity levels", async () => { const multiSeverityResult: ScanResult = { ...mockScanResult, findings: [ { - file: '/test/path/contract.sol', + file: "/test/path/contract.sol", line: 10, - ruleId: 'SOL-001', - ruleName: 'string-to-bytes32', - severity: 'critical', - message: 'Critical issue', + ruleId: "SOL-001", + ruleName: "string-to-bytes32", + severity: "critical", + message: "Critical issue", gasSavings: 5000, confidence: 0.9, }, { - file: '/test/path/contract.sol', + file: "/test/path/contract.sol", line: 20, - ruleId: 'SOL-002', - ruleName: 'uint256', - severity: 'warning', - message: 'Warning issue', + ruleId: "SOL-002", + ruleName: "uint256", + severity: "warning", + message: "Warning issue", gasSavings: 2100, confidence: 0.8, }, { - file: '/test/path/contract.sol', + file: "/test/path/contract.sol", line: 30, - ruleId: 'SOL-003', - ruleName: 'calldata', - severity: 'info', - message: 'Info issue', + ruleId: "SOL-003", + ruleName: "calldata", + severity: "info", + message: "Info issue", gasSavings: 100, confidence: 0.7, }, @@ -156,25 +162,25 @@ describe('SARIF Reporter', () => { info: 1, }, byRule: { - 'SOL-001': 1, - 'SOL-002': 1, - 'SOL-003': 1, + "SOL-001": 1, + "SOL-002": 1, + "SOL-003": 1, }, totalGasSavings: 7200, }, }; - - const outputPath = path.join(__dirname, 'test-severity.sarif.json'); - + + const outputPath = path.join(__dirname, "test-severity.sarif.json"); + await generateSarifReport(multiSeverityResult, outputPath); - + const report = await fs.readJson(outputPath); const results = report.runs[0].results; - - expect(results[0].level).toBe('error'); // critical -> error - expect(results[1].level).toBe('warning'); // warning -> warning - expect(results[2].level).toBe('note'); // info -> note - + + expect(results[0].level).toBe("error"); // critical -> error + expect(results[1].level).toBe("warning"); // warning -> warning + expect(results[2].level).toBe("note"); // info -> note + await fs.remove(outputPath); }); }); diff --git a/packages/cli/src/reporting/sarif-reporter.ts b/packages/cli/src/reporting/sarif-reporter.ts index 04561bb..a3d5e75 100644 --- a/packages/cli/src/reporting/sarif-reporter.ts +++ b/packages/cli/src/reporting/sarif-reporter.ts @@ -1,5 +1,5 @@ -import fs from 'fs-extra'; -import path from 'path'; +import fs from "fs-extra"; +import path from "path"; export interface Finding { file: string; @@ -70,7 +70,7 @@ interface SarifRule { interface SarifRuleProperties { category: string; precision: string; - 'security-severity'?: string; + "security-severity"?: string; } interface SarifMessage { @@ -130,34 +130,39 @@ interface SarifInvocation { executionSuccessful: boolean; } -export async function generateSarifReport(results: ScanResult, outputPath: string): Promise { +export async function generateSarifReport( + results: ScanResult, + outputPath: string, +): Promise { const log = createSarifLog(results); - + // Ensure output directory exists const outputDir = path.dirname(outputPath); await fs.ensureDir(outputDir); - + // Write SARIF report await fs.writeJson(outputPath, log, { spaces: 2 }); } function createSarifLog(results: ScanResult): SarifLog { const rules = extractRules(results.findings); - const sarifResults = results.findings.map(finding => convertFinding(finding)); - + const sarifResults = results.findings.map((finding) => + convertFinding(finding), + ); + const startTime = new Date(results.timestamp); const endTime = new Date(startTime.getTime() + 1000); // Simplified timing - + return { - version: '2.1.0', - $schema: 'https://json.schemastore.org/sarif-2.1.0.json', + version: "2.1.0", + $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [ { tool: { driver: { - name: 'GasGuard', - version: '1.0.0', - informationUri: 'https://github.com/Nabeelahh/GasGuard', + name: "GasGuard", + version: "1.0.0", + informationUri: "https://github.com/Nabeelahh/GasGuard", rules, }, }, @@ -176,12 +181,12 @@ function createSarifLog(results: ScanResult): SarifLog { function extractRules(findings: Finding[]): SarifRule[] { const rulesMap = new Map(); - + for (const finding of findings) { if (!rulesMap.has(finding.ruleId)) { const category = categorizeRule(finding.ruleId); const severity = severityToSecurityLevel(finding.severity); - + rulesMap.set(finding.ruleId, { id: finding.ruleId, shortDescription: { @@ -195,46 +200,48 @@ function extractRules(findings: Finding[]): SarifRule[] { }, properties: { category, - precision: 'medium', - 'security-severity': severity, + precision: "medium", + "security-severity": severity, }, }); } } - + return Array.from(rulesMap.values()); } function convertFinding(finding: Finding): SarifResult { const level = severityToLevel(finding.severity); - const fixes = finding.suggestion ? [ - { - description: { - text: 'Suggested fix', - }, - artifactChanges: [ + const fixes = finding.suggestion + ? [ { - artifactLocation: { - uri: finding.file, + description: { + text: "Suggested fix", }, - replacements: [ + artifactChanges: [ { - region: { - startLine: finding.line, - startColumn: undefined, - endLine: finding.line, - endColumn: undefined, - }, - insertedContent: { - text: finding.suggestion, + artifactLocation: { + uri: finding.file, }, + replacements: [ + { + region: { + startLine: finding.line, + startColumn: undefined, + endLine: finding.line, + endColumn: undefined, + }, + insertedContent: { + text: finding.suggestion, + }, + }, + ], }, ], }, - ], - }, - ] : undefined; - + ] + : undefined; + return { ruleId: finding.ruleId, level, @@ -262,49 +269,49 @@ function convertFinding(finding: Finding): SarifResult { function severityToLevel(severity: string): string { switch (severity.toLowerCase()) { - case 'critical': - case 'error': - return 'error'; - case 'warning': - return 'warning'; - case 'info': - return 'note'; + case "critical": + case "error": + return "error"; + case "warning": + return "warning"; + case "info": + return "note"; default: - return 'note'; + return "note"; } } function severityToSecurityLevel(severity: string): string { switch (severity.toLowerCase()) { - case 'critical': - return '9.0'; - case 'error': - return '7.0'; - case 'warning': - return '4.0'; - case 'info': - return '0.0'; + case "critical": + return "9.0"; + case "error": + return "7.0"; + case "warning": + return "4.0"; + case "info": + return "0.0"; default: - return '0.0'; + return "0.0"; } } function categorizeRule(ruleId: string): string { - if (ruleId.startsWith('SOL-')) return 'solidity'; - if (ruleId.startsWith('VY-')) return 'vyper'; - if (ruleId.startsWith('RS-')) return 'rust'; - if (ruleId.startsWith('SOR-')) return 'soroban'; - return 'general'; + if (ruleId.startsWith("SOL-")) return "solidity"; + if (ruleId.startsWith("VY-")) return "vyper"; + if (ruleId.startsWith("RS-")) return "rust"; + if (ruleId.startsWith("SOR-")) return "soroban"; + return "general"; } function getRuleShortDescription(ruleId: string): string { switch (ruleId) { - case 'SOL-001': - return 'Use bytes32 instead of string for fixed-length data'; - case 'SOL-002': - return 'Use uint256 instead of uint'; - case 'SOL-003': - return 'Use calldata instead of memory for read-only arguments'; + case "SOL-001": + return "Use bytes32 instead of string for fixed-length data"; + case "SOL-002": + return "Use uint256 instead of uint"; + case "SOL-003": + return "Use calldata instead of memory for read-only arguments"; default: return `Gas optimization rule ${ruleId}`; } @@ -312,12 +319,12 @@ function getRuleShortDescription(ruleId: string): string { function getRuleFullDescription(ruleId: string): string { switch (ruleId) { - case 'SOL-001': - return 'Using bytes32 instead of string for fixed-length data can save gas'; - case 'SOL-002': - return 'Using uint256 instead of uint can save gas by avoiding type conversion'; - case 'SOL-003': - return 'Using calldata instead of memory for read-only arguments can save gas'; + case "SOL-001": + return "Using bytes32 instead of string for fixed-length data can save gas"; + case "SOL-002": + return "Using uint256 instead of uint can save gas by avoiding type conversion"; + case "SOL-003": + return "Using calldata instead of memory for read-only arguments can save gas"; default: return `Gas optimization rule: ${ruleId}`; } diff --git a/packages/cli/src/reporting/summary-printer.ts b/packages/cli/src/reporting/summary-printer.ts index f81f7c4..40c1502 100644 --- a/packages/cli/src/reporting/summary-printer.ts +++ b/packages/cli/src/reporting/summary-printer.ts @@ -1,42 +1,62 @@ -import chalk from 'chalk'; -import { ScanResult } from './json-reporter'; +import chalk from "chalk"; +import { ScanResult } from "./json-reporter"; export interface SummaryOptions { fixPreview?: boolean; confidence?: number; } -export function printSummary(results: ScanResult, options: SummaryOptions = {}): void { - console.log('\n' + chalk.bold.blue('═══════════════════════════════════════════════════════════')); - console.log(chalk.bold.blue(' GasGuard Scan Report')); - console.log(chalk.bold.blue('═══════════════════════════════════════════════════════════\n')); +export function printSummary( + results: ScanResult, + options: SummaryOptions = {}, +): void { + console.log( + "\n" + + chalk.bold.blue( + "═══════════════════════════════════════════════════════════", + ), + ); + console.log(chalk.bold.blue(" GasGuard Scan Report")); + console.log( + chalk.bold.blue( + "═══════════════════════════════════════════════════════════\n", + ), + ); // Scan metadata console.log(chalk.gray(`Scan Path: ${results.scanPath}`)); console.log(chalk.gray(`Timestamp: ${results.timestamp}`)); - console.log(chalk.gray(`Files Scanned: ${results.scannedFiles}/${results.totalFiles}\n`)); + console.log( + chalk.gray( + `Files Scanned: ${results.scannedFiles}/${results.totalFiles}\n`, + ), + ); // Summary statistics - console.log(chalk.bold('Summary Statistics:')); - console.log(` Total Violations: ${chalk.yellow(results.summary.totalViolations.toString())}`); - console.log(` Total Gas Savings: ${chalk.green(formatGasSavings(results.summary.totalGasSavings))}\n`); + console.log(chalk.bold("Summary Statistics:")); + console.log( + ` Total Violations: ${chalk.yellow(results.summary.totalViolations.toString())}`, + ); + console.log( + ` Total Gas Savings: ${chalk.green(formatGasSavings(results.summary.totalGasSavings))}\n`, + ); // By severity - console.log(chalk.bold('Violations by Severity:')); - printSeverity('Critical', results.summary.bySeverity.critical, 'red'); - printSeverity('High', results.summary.bySeverity.high, 'yellow'); - printSeverity('Medium', results.summary.bySeverity.medium, 'yellow'); - printSeverity('Low', results.summary.bySeverity.low, 'blue'); - printSeverity('Info', results.summary.bySeverity.info, 'gray'); + console.log(chalk.bold("Violations by Severity:")); + printSeverity("Critical", results.summary.bySeverity.critical, "red"); + printSeverity("High", results.summary.bySeverity.high, "yellow"); + printSeverity("Medium", results.summary.bySeverity.medium, "yellow"); + printSeverity("Low", results.summary.bySeverity.low, "blue"); + printSeverity("Info", results.summary.bySeverity.info, "gray"); console.log(); // By rule if (Object.keys(results.summary.byRule).length > 0) { - console.log(chalk.bold('Violations by Rule:')); + console.log(chalk.bold("Violations by Rule:")); const sortedRules = Object.entries(results.summary.byRule) .sort(([, a], [, b]) => b - a) .slice(0, 10); // Show top 10 - + for (const [rule, count] of sortedRules) { console.log(` ${chalk.cyan(rule)}: ${count}`); } @@ -45,43 +65,57 @@ export function printSummary(results: ScanResult, options: SummaryOptions = {}): // Findings details if (results.findings.length > 0) { - console.log(chalk.bold('Findings Details:')); - console.log(chalk.gray('─'.repeat(60))); - - for (const finding of results.findings.slice(0, 20)) { // Show first 20 + console.log(chalk.bold("Findings Details:")); + console.log(chalk.gray("─".repeat(60))); + + for (const finding of results.findings.slice(0, 20)) { + // Show first 20 printFinding(finding, options); } - + if (results.findings.length > 20) { - console.log(chalk.gray(`\n... and ${results.findings.length - 20} more findings`)); + console.log( + chalk.gray(`\n... and ${results.findings.length - 20} more findings`), + ); } console.log(); } - console.log(chalk.bold.blue('═══════════════════════════════════════════════════════════\n')); + console.log( + chalk.bold.blue( + "═══════════════════════════════════════════════════════════\n", + ), + ); } function printSeverity(label: string, count: number, color: string): void { - const coloredCount = count > 0 ? chalk[color](count.toString()) : chalk.gray('0'); + const coloredCount = + count > 0 ? chalk[color](count.toString()) : chalk.gray("0"); console.log(` ${label.padEnd(10)}: ${coloredCount}`); } function printFinding(finding: any, options: SummaryOptions): void { const severityColor = getSeverityColor(finding.severity); - const confidence = finding.confidence ? ` (confidence: ${finding.confidence.toFixed(2)})` : ''; - - console.log(`\n${chalk.bold(severityColor(`[${finding.severity.toUpperCase()}]`))} ${finding.ruleId} - ${finding.ruleName}`); + const confidence = finding.confidence + ? ` (confidence: ${finding.confidence.toFixed(2)})` + : ""; + + console.log( + `\n${chalk.bold(severityColor(`[${finding.severity.toUpperCase()}]`))} ${finding.ruleId} - ${finding.ruleName}`, + ); console.log(chalk.gray(` File: ${finding.file}:${finding.line}`)); console.log(chalk.gray(` Message: ${finding.message}`)); - + if (finding.gasSavings) { - console.log(chalk.green(` Gas Savings: ${formatGasSavings(finding.gasSavings)}`)); + console.log( + chalk.green(` Gas Savings: ${formatGasSavings(finding.gasSavings)}`), + ); } - + if (confidence) { console.log(chalk.gray(confidence)); } - + if (options.fixPreview && finding.suggestion) { console.log(chalk.cyan(` Suggestion: ${finding.suggestion}`)); } @@ -89,16 +123,16 @@ function printFinding(finding: any, options: SummaryOptions): void { function getSeverityColor(severity: string): (text: string) => string { switch (severity.toLowerCase()) { - case 'critical': - case 'error': + case "critical": + case "error": return chalk.red; - case 'high': + case "high": return chalk.yellow; - case 'medium': + case "medium": return chalk.yellow; - case 'low': + case "low": return chalk.blue; - case 'info': + case "info": default: return chalk.gray; } @@ -118,13 +152,15 @@ export function printCompactSummary(results: ScanResult): void { const high = results.summary.bySeverity.high; const medium = results.summary.bySeverity.medium; const low = results.summary.bySeverity.low; - - let status = chalk.green('✓ PASS'); + + let status = chalk.green("✓ PASS"); if (critical > 0) { - status = chalk.red('✗ FAIL'); + status = chalk.red("✗ FAIL"); } else if (high > 0) { - status = chalk.yellow('⚠ WARN'); + status = chalk.yellow("⚠ WARN"); } - - console.log(`${status} ${results.summary.totalViolations} issues found (${critical} critical, ${high} high, ${medium} medium, ${low} low) - ${formatGasSavings(results.summary.totalGasSavings)} potential savings`); + + console.log( + `${status} ${results.summary.totalViolations} issues found (${critical} critical, ${high} high, ${medium} medium, ${low} low) - ${formatGasSavings(results.summary.totalGasSavings)} potential savings`, + ); } diff --git a/packages/config/config-schema.ts b/packages/config/config-schema.ts index 6d0eae3..aa91ae9 100644 --- a/packages/config/config-schema.ts +++ b/packages/config/config-schema.ts @@ -1,6 +1,6 @@ /** * Configuration Schema Definitions - * + * * JSON Schema definitions for configuration validation */ @@ -14,58 +14,65 @@ export const CONFIGURATION_SCHEMA = { version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+(-.*)?$", - description: "Configuration version (semantic versioning)" + description: "Configuration version (semantic versioning)", }, lastUpdated: { type: "string", format: "date-time", - description: "Last updated timestamp" + description: "Last updated timestamp", }, system: { - $ref: "#/definitions/SystemConfiguration" + $ref: "#/definitions/SystemConfiguration", }, rules: { type: "array", items: { - $ref: "#/definitions/RuleConfiguration" + $ref: "#/definitions/RuleConfiguration", }, - description: "Array of rule configurations" + description: "Array of rule configurations", }, profiles: { type: "array", items: { - $ref: "#/definitions/ConfigurationProfile" + $ref: "#/definitions/ConfigurationProfile", }, - description: "Configuration profiles for different use cases" - } + description: "Configuration profiles for different use cases", + }, }, definitions: { SystemConfiguration: { type: "object", - required: ["version", "environment", "logging", "performance", "security", "features"], + required: [ + "version", + "environment", + "logging", + "performance", + "security", + "features", + ], properties: { version: { type: "string", - description: "System version" + description: "System version", }, environment: { type: "string", enum: ["development", "staging", "production"], - description: "Runtime environment" + description: "Runtime environment", }, logging: { - $ref: "#/definitions/LoggingConfiguration" + $ref: "#/definitions/LoggingConfiguration", }, performance: { - $ref: "#/definitions/PerformanceConfiguration" + $ref: "#/definitions/PerformanceConfiguration", }, security: { - $ref: "#/definitions/SecurityConfiguration" + $ref: "#/definitions/SecurityConfiguration", }, features: { - $ref: "#/definitions/FeatureConfiguration" - } - } + $ref: "#/definitions/FeatureConfiguration", + }, + }, }, LoggingConfiguration: { type: "object", @@ -74,21 +81,21 @@ export const CONFIGURATION_SCHEMA = { level: { type: "string", enum: ["debug", "info", "warn", "error"], - description: "Minimum log level" + description: "Minimum log level", }, enableConsole: { type: "boolean", - description: "Enable console logging" + description: "Enable console logging", }, enableFile: { type: "boolean", - description: "Enable file logging" + description: "Enable file logging", }, enableAudit: { type: "boolean", - description: "Enable audit logging" - } - } + description: "Enable audit logging", + }, + }, }, PerformanceConfiguration: { type: "object", @@ -98,117 +105,133 @@ export const CONFIGURATION_SCHEMA = { type: "integer", minimum: 1, maximum: 32, - description: "Maximum concurrent rule executions" + description: "Maximum concurrent rule executions", }, timeoutMs: { type: "integer", minimum: 0, - description: "Timeout in milliseconds for rule execution" + description: "Timeout in milliseconds for rule execution", }, enableParallelExecution: { type: "boolean", - description: "Enable parallel rule execution" - } - } + description: "Enable parallel rule execution", + }, + }, }, SecurityConfiguration: { type: "object", - required: ["enableApiKeyValidation", "enableRateLimiting", "maxRequestsPerMinute"], + required: [ + "enableApiKeyValidation", + "enableRateLimiting", + "maxRequestsPerMinute", + ], properties: { enableApiKeyValidation: { type: "boolean", - description: "Enable API key validation" + description: "Enable API key validation", }, enableRateLimiting: { type: "boolean", - description: "Enable rate limiting" + description: "Enable rate limiting", }, maxRequestsPerMinute: { type: "integer", minimum: 1, - description: "Maximum requests per minute" - } - } + description: "Maximum requests per minute", + }, + }, }, FeatureConfiguration: { type: "object", - required: ["enableAutoFix", "enableDetailedReporting", "enableRealTimeMonitoring"], + required: [ + "enableAutoFix", + "enableDetailedReporting", + "enableRealTimeMonitoring", + ], properties: { enableAutoFix: { type: "boolean", - description: "Enable automatic fix suggestions" + description: "Enable automatic fix suggestions", }, enableDetailedReporting: { type: "boolean", - description: "Enable detailed reporting" + description: "Enable detailed reporting", }, enableRealTimeMonitoring: { type: "boolean", - description: "Enable real-time monitoring" - } - } + description: "Enable real-time monitoring", + }, + }, }, RuleConfiguration: { type: "object", - required: ["id", "version", "name", "enabled", "severity", "category", "language"], + required: [ + "id", + "version", + "name", + "enabled", + "severity", + "category", + "language", + ], properties: { id: { type: "string", pattern: "^[a-z0-9-]+$", - description: "Unique rule identifier" + description: "Unique rule identifier", }, version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+(-.*)?$", - description: "Rule version (semantic versioning)" + description: "Rule version (semantic versioning)", }, name: { type: "string", - description: "Human-readable rule name" + description: "Human-readable rule name", }, enabled: { type: "boolean", - description: "Whether the rule is enabled" + description: "Whether the rule is enabled", }, severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"], - description: "Rule severity level" + description: "Rule severity level", }, category: { type: "string", - description: "Rule category" + description: "Rule category", }, language: { type: "string", - description: "Target programming language" + description: "Target programming language", }, description: { type: "string", - description: "Rule description" + description: "Rule description", }, parameters: { type: "object", - description: "Rule-specific parameters" + description: "Rule-specific parameters", }, dependencies: { type: "array", items: { - type: "string" + type: "string", }, - description: "Rule dependencies" + description: "Rule dependencies", }, tags: { type: "array", items: { - type: "string" + type: "string", }, - description: "Rule tags" + description: "Rule tags", }, customRules: { - $ref: "#/definitions/CustomRules" - } - } + $ref: "#/definitions/CustomRules", + }, + }, }, CustomRules: { type: "object", @@ -216,23 +239,23 @@ export const CONFIGURATION_SCHEMA = { properties: { enabled: { type: "boolean", - description: "Whether custom rules are enabled" + description: "Whether custom rules are enabled", }, conditions: { type: "array", items: { - $ref: "#/definitions/RuleCondition" + $ref: "#/definitions/RuleCondition", }, - description: "Custom rule conditions" + description: "Custom rule conditions", }, actions: { type: "array", items: { - $ref: "#/definitions/RuleAction" + $ref: "#/definitions/RuleAction", }, - description: "Custom rule actions" - } - } + description: "Custom rule actions", + }, + }, }, RuleCondition: { type: "object", @@ -240,22 +263,31 @@ export const CONFIGURATION_SCHEMA = { properties: { field: { type: "string", - description: "Field to evaluate" + description: "Field to evaluate", }, operator: { type: "string", - enum: ["equals", "not_equals", "contains", "not_contains", "greater_than", "less_than", "in", "not_in"], - description: "Comparison operator" + enum: [ + "equals", + "not_equals", + "contains", + "not_contains", + "greater_than", + "less_than", + "in", + "not_in", + ], + description: "Comparison operator", }, value: { - description: "Value to compare against" + description: "Value to compare against", }, caseSensitive: { type: "boolean", default: true, - description: "Whether comparison is case sensitive" - } - } + description: "Whether comparison is case sensitive", + }, + }, }, RuleAction: { type: "object", @@ -264,22 +296,22 @@ export const CONFIGURATION_SCHEMA = { type: { type: "string", enum: ["warn", "error", "info", "custom"], - description: "Action type" + description: "Action type", }, message: { type: "string", - description: "Action message" + description: "Action message", }, severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"], - description: "Action severity" + description: "Action severity", }, metadata: { type: "object", - description: "Additional metadata" - } - } + description: "Additional metadata", + }, + }, }, ConfigurationProfile: { type: "object", @@ -287,92 +319,100 @@ export const CONFIGURATION_SCHEMA = { properties: { name: { type: "string", - description: "Profile name" + description: "Profile name", }, description: { type: "string", - description: "Profile description" + description: "Profile description", }, rules: { type: "array", items: { - $ref: "#/definitions/RuleConfiguration" + $ref: "#/definitions/RuleConfiguration", }, - description: "Rule configurations for this profile" + description: "Rule configurations for this profile", }, systemOverrides: { $ref: "#/definitions/SystemConfiguration", - description: "System configuration overrides" - } - } - } - } + description: "System configuration overrides", + }, + }, + }, + }, }; export const RULE_CONFIGURATION_SCHEMA = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", title: "Rule Configuration", - required: ["id", "version", "name", "enabled", "severity", "category", "language"], + required: [ + "id", + "version", + "name", + "enabled", + "severity", + "category", + "language", + ], properties: { id: { type: "string", pattern: "^[a-z0-9-]+$", - description: "Unique rule identifier" + description: "Unique rule identifier", }, version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+(-.*)?$", - description: "Rule version (semantic versioning)" + description: "Rule version (semantic versioning)", }, name: { type: "string", - description: "Human-readable rule name" + description: "Human-readable rule name", }, enabled: { type: "boolean", - description: "Whether the rule is enabled" + description: "Whether the rule is enabled", }, severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"], - description: "Rule severity level" + description: "Rule severity level", }, category: { type: "string", - description: "Rule category" + description: "Rule category", }, language: { type: "string", - description: "Target programming language" + description: "Target programming language", }, description: { type: "string", - description: "Rule description" + description: "Rule description", }, parameters: { type: "object", - description: "Rule-specific parameters" + description: "Rule-specific parameters", }, dependencies: { type: "array", items: { - type: "string" + type: "string", }, - description: "Rule dependencies" + description: "Rule dependencies", }, tags: { type: "array", items: { - type: "string" + type: "string", }, - description: "Rule tags" + description: "Rule tags", }, customRules: { - $ref: "#/definitions/CustomRules" - } + $ref: "#/definitions/CustomRules", + }, }, definitions: { - CustomRules: CONFIGURATION_SCHEMA.definitions.CustomRules - } + CustomRules: CONFIGURATION_SCHEMA.definitions.CustomRules, + }, }; diff --git a/packages/config/config-validator.ts b/packages/config/config-validator.ts index 3fcc60e..5abaded 100644 --- a/packages/config/config-validator.ts +++ b/packages/config/config-validator.ts @@ -1,39 +1,41 @@ /** * Configuration Validator - * + * * Validates configuration files and schemas */ -import { - ConfigurationFile, - RuleConfiguration, +import { + ConfigurationFile, + RuleConfiguration, SystemConfiguration, ConfigurationValidationResult, ValidationError, - ValidationWarning -} from '../../src/config/config.types'; + ValidationWarning, +} from "../../src/config/config.types"; export class ConfigValidator { /** * Validate complete configuration file */ - validateConfiguration(config: ConfigurationFile): ConfigurationValidationResult { + validateConfiguration( + config: ConfigurationFile, + ): ConfigurationValidationResult { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; // Validate top-level structure this.validateTopLevel(config, errors, warnings); - + // Validate system configuration if (config.system) { this.validateSystemConfig(config.system, errors, warnings); } - + // Validate rules if (config.rules) { this.validateRules(config.rules, errors, warnings); } - + // Validate profiles if (config.profiles) { this.validateProfiles(config.profiles, errors, warnings); @@ -46,141 +48,166 @@ export class ConfigValidator { }; } - private validateTopLevel(config: ConfigurationFile, errors: ValidationError[], warnings: ValidationWarning[]): void { + private validateTopLevel( + config: ConfigurationFile, + errors: ValidationError[], + warnings: ValidationWarning[], + ): void { if (!config.version) { errors.push({ - path: 'version', - message: 'Configuration version is required', - code: 'MISSING_VERSION', + path: "version", + message: "Configuration version is required", + code: "MISSING_VERSION", }); } else if (!this.isValidVersion(config.version)) { errors.push({ - path: 'version', - message: 'Invalid version format (should be semantic version like 1.0.0)', - code: 'INVALID_VERSION_FORMAT', + path: "version", + message: + "Invalid version format (should be semantic version like 1.0.0)", + code: "INVALID_VERSION_FORMAT", }); } if (!config.lastUpdated) { warnings.push({ - path: 'lastUpdated', - message: 'Last updated timestamp is missing', - code: 'MISSING_LAST_UPDATED', + path: "lastUpdated", + message: "Last updated timestamp is missing", + code: "MISSING_LAST_UPDATED", }); } } - private validateSystemConfig(system: SystemConfiguration, errors: ValidationError[], warnings: ValidationWarning[]): void { + private validateSystemConfig( + system: SystemConfiguration, + errors: ValidationError[], + warnings: ValidationWarning[], + ): void { if (!system.version) { errors.push({ - path: 'system.version', - message: 'System version is required', - code: 'MISSING_SYSTEM_VERSION', + path: "system.version", + message: "System version is required", + code: "MISSING_SYSTEM_VERSION", }); } - if (!['development', 'staging', 'production'].includes(system.environment)) { + if ( + !["development", "staging", "production"].includes(system.environment) + ) { errors.push({ - path: 'system.environment', - message: 'Invalid environment (must be development, staging, or production)', - code: 'INVALID_ENVIRONMENT', + path: "system.environment", + message: + "Invalid environment (must be development, staging, or production)", + code: "INVALID_ENVIRONMENT", }); } // Validate logging configuration if (system.logging) { - if (!['debug', 'info', 'warn', 'error'].includes(system.logging.level)) { + if (!["debug", "info", "warn", "error"].includes(system.logging.level)) { errors.push({ - path: 'system.logging.level', - message: 'Invalid logging level', - code: 'INVALID_LOG_LEVEL', + path: "system.logging.level", + message: "Invalid logging level", + code: "INVALID_LOG_LEVEL", }); } - if (typeof system.logging.enableConsole !== 'boolean') { + if (typeof system.logging.enableConsole !== "boolean") { errors.push({ - path: 'system.logging.enableConsole', - message: 'enableConsole must be boolean', - code: 'INVALID_ENABLE_CONSOLE', + path: "system.logging.enableConsole", + message: "enableConsole must be boolean", + code: "INVALID_ENABLE_CONSOLE", }); } - if (typeof system.logging.enableFile !== 'boolean') { + if (typeof system.logging.enableFile !== "boolean") { errors.push({ - path: 'system.logging.enableFile', - message: 'enableFile must be boolean', - code: 'INVALID_ENABLE_FILE', + path: "system.logging.enableFile", + message: "enableFile must be boolean", + code: "INVALID_ENABLE_FILE", }); } - if (typeof system.logging.enableAudit !== 'boolean') { + if (typeof system.logging.enableAudit !== "boolean") { errors.push({ - path: 'system.logging.enableAudit', - message: 'enableAudit must be boolean', - code: 'INVALID_ENABLE_AUDIT', + path: "system.logging.enableAudit", + message: "enableAudit must be boolean", + code: "INVALID_ENABLE_AUDIT", }); } } // Validate performance configuration if (system.performance) { - if (typeof system.performance.maxConcurrency !== 'number' || system.performance.maxConcurrency < 1) { + if ( + typeof system.performance.maxConcurrency !== "number" || + system.performance.maxConcurrency < 1 + ) { errors.push({ - path: 'system.performance.maxConcurrency', - message: 'maxConcurrency must be a positive number', - code: 'INVALID_MAX_CONCURRENCY', + path: "system.performance.maxConcurrency", + message: "maxConcurrency must be a positive number", + code: "INVALID_MAX_CONCURRENCY", }); } - if (typeof system.performance.timeoutMs !== 'number' || system.performance.timeoutMs < 0) { + if ( + typeof system.performance.timeoutMs !== "number" || + system.performance.timeoutMs < 0 + ) { errors.push({ - path: 'system.performance.timeoutMs', - message: 'timeoutMs must be a non-negative number', - code: 'INVALID_TIMEOUT', + path: "system.performance.timeoutMs", + message: "timeoutMs must be a non-negative number", + code: "INVALID_TIMEOUT", }); } - if (typeof system.performance.enableParallelExecution !== 'boolean') { + if (typeof system.performance.enableParallelExecution !== "boolean") { errors.push({ - path: 'system.performance.enableParallelExecution', - message: 'enableParallelExecution must be boolean', - code: 'INVALID_PARALLEL_EXECUTION', + path: "system.performance.enableParallelExecution", + message: "enableParallelExecution must be boolean", + code: "INVALID_PARALLEL_EXECUTION", }); } } // Validate security configuration if (system.security) { - if (typeof system.security.enableApiKeyValidation !== 'boolean') { + if (typeof system.security.enableApiKeyValidation !== "boolean") { errors.push({ - path: 'system.security.enableApiKeyValidation', - message: 'enableApiKeyValidation must be boolean', - code: 'INVALID_API_KEY_VALIDATION', + path: "system.security.enableApiKeyValidation", + message: "enableApiKeyValidation must be boolean", + code: "INVALID_API_KEY_VALIDATION", }); } - if (typeof system.security.enableRateLimiting !== 'boolean') { + if (typeof system.security.enableRateLimiting !== "boolean") { errors.push({ - path: 'system.security.enableRateLimiting', - message: 'enableRateLimiting must be boolean', - code: 'INVALID_RATE_LIMITING', + path: "system.security.enableRateLimiting", + message: "enableRateLimiting must be boolean", + code: "INVALID_RATE_LIMITING", }); } - if (typeof system.security.maxRequestsPerMinute !== 'number' || system.security.maxRequestsPerMinute < 1) { + if ( + typeof system.security.maxRequestsPerMinute !== "number" || + system.security.maxRequestsPerMinute < 1 + ) { errors.push({ - path: 'system.security.maxRequestsPerMinute', - message: 'maxRequestsPerMinute must be a positive number', - code: 'INVALID_MAX_REQUESTS', + path: "system.security.maxRequestsPerMinute", + message: "maxRequestsPerMinute must be a positive number", + code: "INVALID_MAX_REQUESTS", }); } } // Validate features configuration if (system.features) { - const featureFlags = ['enableAutoFix', 'enableDetailedReporting', 'enableRealTimeMonitoring']; - featureFlags.forEach(flag => { - if (typeof (system.features as any)[flag] !== 'boolean') { + const featureFlags = [ + "enableAutoFix", + "enableDetailedReporting", + "enableRealTimeMonitoring", + ]; + featureFlags.forEach((flag) => { + if (typeof (system.features as any)[flag] !== "boolean") { errors.push({ path: `system.features.${flag}`, message: `${flag} must be boolean`, @@ -191,7 +218,11 @@ export class ConfigValidator { } } - private validateRules(rules: RuleConfiguration[], errors: ValidationError[], warnings: ValidationWarning[]): void { + private validateRules( + rules: RuleConfiguration[], + errors: ValidationError[], + warnings: ValidationWarning[], + ): void { const ruleIds = new Set(); rules.forEach((rule, index) => { @@ -201,15 +232,15 @@ export class ConfigValidator { if (!rule.id) { errors.push({ path: `${prefix}.id`, - message: 'Rule ID is required', - code: 'MISSING_RULE_ID', + message: "Rule ID is required", + code: "MISSING_RULE_ID", }); } else { if (ruleIds.has(rule.id)) { errors.push({ path: `${prefix}.id`, message: `Duplicate rule ID: ${rule.id}`, - code: 'DUPLICATE_RULE_ID', + code: "DUPLICATE_RULE_ID", }); } else { ruleIds.add(rule.id); @@ -218,8 +249,9 @@ export class ConfigValidator { if (!this.isValidRuleId(rule.id)) { errors.push({ path: `${prefix}.id`, - message: 'Invalid rule ID format (should be lowercase, alphanumeric, hyphens only)', - code: 'INVALID_RULE_ID_FORMAT', + message: + "Invalid rule ID format (should be lowercase, alphanumeric, hyphens only)", + code: "INVALID_RULE_ID_FORMAT", }); } } @@ -227,71 +259,73 @@ export class ConfigValidator { if (!rule.version) { errors.push({ path: `${prefix}.version`, - message: 'Rule version is required', - code: 'MISSING_RULE_VERSION', + message: "Rule version is required", + code: "MISSING_RULE_VERSION", }); } else if (!this.isValidVersion(rule.version)) { errors.push({ path: `${prefix}.version`, - message: 'Invalid rule version format', - code: 'INVALID_RULE_VERSION_FORMAT', + message: "Invalid rule version format", + code: "INVALID_RULE_VERSION_FORMAT", }); } if (!rule.name) { errors.push({ path: `${prefix}.name`, - message: 'Rule name is required', - code: 'MISSING_RULE_NAME', + message: "Rule name is required", + code: "MISSING_RULE_NAME", }); } - if (typeof rule.enabled !== 'boolean') { + if (typeof rule.enabled !== "boolean") { errors.push({ path: `${prefix}.enabled`, - message: 'Rule enabled flag must be boolean', - code: 'INVALID_ENABLED_FLAG', + message: "Rule enabled flag must be boolean", + code: "INVALID_ENABLED_FLAG", }); } - if (!['critical', 'high', 'medium', 'low', 'info'].includes(rule.severity)) { + if ( + !["critical", "high", "medium", "low", "info"].includes(rule.severity) + ) { errors.push({ path: `${prefix}.severity`, - message: 'Invalid severity level', - code: 'INVALID_SEVERITY', + message: "Invalid severity level", + code: "INVALID_SEVERITY", }); } if (!rule.category) { warnings.push({ path: `${prefix}.category`, - message: 'Rule category is recommended', - code: 'MISSING_CATEGORY', + message: "Rule category is recommended", + code: "MISSING_CATEGORY", }); } else if (!this.isValidCategory(rule.category)) { warnings.push({ path: `${prefix}.category`, - message: 'Unknown category', - code: 'UNKNOWN_CATEGORY', + message: "Unknown category", + code: "UNKNOWN_CATEGORY", }); } if (!rule.language) { warnings.push({ path: `${prefix}.language`, - message: 'Rule language is recommended', - code: 'MISSING_LANGUAGE', + message: "Rule language is recommended", + code: "MISSING_LANGUAGE", }); } // Validate dependencies if (rule.dependencies && Array.isArray(rule.dependencies)) { rule.dependencies.forEach((dep, depIndex) => { - if (typeof dep !== 'string') { + if (typeof dep !== "string") { errors.push({ path: `${prefix}.dependencies[${depIndex}]`, - message: 'Dependency must be a string', - code: 'INVALID_DEPENDENCY', + message: "Dependency must be a string", + code: "INVALID_DEPENDENCY", }); } }); @@ -300,45 +334,59 @@ export class ConfigValidator { if (rule.id && rule.dependencies.includes(rule.id)) { errors.push({ path: `${prefix}.dependencies`, - message: 'Rule cannot depend on itself', - code: 'SELF_DEPENDENCY', + message: "Rule cannot depend on itself", + code: "SELF_DEPENDENCY", }); } } // Validate custom rules if (rule.customRules) { - this.validateCustomRules(rule.customRules, `${prefix}.customRules`, errors, warnings); + this.validateCustomRules( + rule.customRules, + `${prefix}.customRules`, + errors, + warnings, + ); } }); } - private validateCustomRules(customRules: any, path: string, errors: ValidationError[], warnings: ValidationWarning[]): void { - if (typeof customRules.enabled !== 'boolean') { + private validateCustomRules( + customRules: any, + path: string, + errors: ValidationError[], + warnings: ValidationWarning[], + ): void { + if (typeof customRules.enabled !== "boolean") { errors.push({ path: `${path}.enabled`, - message: 'Custom rules enabled flag must be boolean', - code: 'INVALID_CUSTOM_RULES_ENABLED', + message: "Custom rules enabled flag must be boolean", + code: "INVALID_CUSTOM_RULES_ENABLED", }); } if (!Array.isArray(customRules.conditions)) { errors.push({ path: `${path}.conditions`, - message: 'Custom rules conditions must be an array', - code: 'INVALID_CONDITIONS_FORMAT', + message: "Custom rules conditions must be an array", + code: "INVALID_CONDITIONS_FORMAT", }); } else { customRules.conditions.forEach((condition: any, index: number) => { - this.validateCondition(condition, `${path}.conditions[${index}]`, errors); + this.validateCondition( + condition, + `${path}.conditions[${index}]`, + errors, + ); }); } if (!Array.isArray(customRules.actions)) { errors.push({ path: `${path}.actions`, - message: 'Custom rules actions must be an array', - code: 'INVALID_ACTIONS_FORMAT', + message: "Custom rules actions must be an array", + code: "INVALID_ACTIONS_FORMAT", }); } else { customRules.actions.forEach((action: any, index: number) => { @@ -347,77 +395,101 @@ export class ConfigValidator { } } - private validateCondition(condition: any, path: string, errors: ValidationError[]): void { - if (!condition.field || typeof condition.field !== 'string') { + private validateCondition( + condition: any, + path: string, + errors: ValidationError[], + ): void { + if (!condition.field || typeof condition.field !== "string") { errors.push({ path: `${path}.field`, - message: 'Condition field is required and must be a string', - code: 'INVALID_CONDITION_FIELD', + message: "Condition field is required and must be a string", + code: "INVALID_CONDITION_FIELD", }); } - const validOperators = ['equals', 'not_equals', 'contains', 'not_contains', 'greater_than', 'less_than', 'in', 'not_in']; + const validOperators = [ + "equals", + "not_equals", + "contains", + "not_contains", + "greater_than", + "less_than", + "in", + "not_in", + ]; if (!validOperators.includes(condition.operator)) { errors.push({ path: `${path}.operator`, - message: 'Invalid condition operator', - code: 'INVALID_CONDITION_OPERATOR', + message: "Invalid condition operator", + code: "INVALID_CONDITION_OPERATOR", }); } if (condition.value === undefined || condition.value === null) { errors.push({ path: `${path}.value`, - message: 'Condition value is required', - code: 'INVALID_CONDITION_VALUE', + message: "Condition value is required", + code: "INVALID_CONDITION_VALUE", }); } } - private validateAction(action: any, path: string, errors: ValidationError[]): void { - const validTypes = ['warn', 'error', 'info', 'custom']; + private validateAction( + action: any, + path: string, + errors: ValidationError[], + ): void { + const validTypes = ["warn", "error", "info", "custom"]; if (!validTypes.includes(action.type)) { errors.push({ path: `${path}.type`, - message: 'Invalid action type', - code: 'INVALID_ACTION_TYPE', + message: "Invalid action type", + code: "INVALID_ACTION_TYPE", }); } - if (action.severity && !['critical', 'high', 'medium', 'low', 'info'].includes(action.severity)) { + if ( + action.severity && + !["critical", "high", "medium", "low", "info"].includes(action.severity) + ) { errors.push({ path: `${path}.severity`, - message: 'Invalid action severity', - code: 'INVALID_ACTION_SEVERITY', + message: "Invalid action severity", + code: "INVALID_ACTION_SEVERITY", }); } } - private validateProfiles(profiles: any[], errors: ValidationError[], warnings: ValidationWarning[]): void { + private validateProfiles( + profiles: any[], + errors: ValidationError[], + warnings: ValidationWarning[], + ): void { profiles.forEach((profile, index) => { const prefix = `profiles[${index}]`; - if (!profile.name || typeof profile.name !== 'string') { + if (!profile.name || typeof profile.name !== "string") { errors.push({ path: `${prefix}.name`, - message: 'Profile name is required and must be a string', - code: 'MISSING_PROFILE_NAME', + message: "Profile name is required and must be a string", + code: "MISSING_PROFILE_NAME", }); } - if (!profile.description || typeof profile.description !== 'string') { + if (!profile.description || typeof profile.description !== "string") { warnings.push({ path: `${prefix}.description`, - message: 'Profile description is recommended', - code: 'MISSING_PROFILE_DESCRIPTION', + message: "Profile description is recommended", + code: "MISSING_PROFILE_DESCRIPTION", }); } if (!Array.isArray(profile.rules)) { errors.push({ path: `${prefix}.rules`, - message: 'Profile rules must be an array', - code: 'INVALID_PROFILE_RULES', + message: "Profile rules must be an array", + code: "INVALID_PROFILE_RULES", }); } }); @@ -433,8 +505,15 @@ export class ConfigValidator { private isValidCategory(category: string): boolean { const validCategories = [ - 'security', 'performance', 'gas-optimization', 'best-practices', - 'solidity', 'soroban', 'vyper', 'rust', 'general' + "security", + "performance", + "gas-optimization", + "best-practices", + "solidity", + "soroban", + "vyper", + "rust", + "general", ]; return validCategories.includes(category); } diff --git a/packages/config/index.ts b/packages/config/index.ts index 378c78d..4d33121 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -1,9 +1,9 @@ /** * GasGuard Configuration Package - * + * * Shared configuration utilities and types for the GasGuard ecosystem */ -export * from './rule-loader'; -export * from './config-validator'; -export * from './config-schema'; +export * from "./rule-loader"; +export * from "./config-validator"; +export * from "./config-schema"; diff --git a/packages/config/redis.ts b/packages/config/redis.ts index ca74033..043fdbf 100644 --- a/packages/config/redis.ts +++ b/packages/config/redis.ts @@ -1,8 +1,8 @@ export const redisConfig = { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT || "6379", 10), password: process.env.REDIS_PASSWORD || undefined, - db: parseInt(process.env.REDIS_DB || '0', 10), - keyPrefix: 'gasguard:', - ttl: parseInt(process.env.CACHE_TTL || '3600', 10), // Default 1 hour + db: parseInt(process.env.REDIS_DB || "0", 10), + keyPrefix: "gasguard:", + ttl: parseInt(process.env.CACHE_TTL || "3600", 10), // Default 1 hour }; diff --git a/packages/config/rule-loader.ts b/packages/config/rule-loader.ts index a07e172..8c11d4c 100644 --- a/packages/config/rule-loader.ts +++ b/packages/config/rule-loader.ts @@ -1,10 +1,10 @@ /** * Dynamic Rule Loader - * + * * Handles dynamic loading and unloading of rules based on configuration */ -import { RuleConfiguration } from '../../src/config/config.types'; +import { RuleConfiguration } from "../../src/config/config.types"; export interface RuleModule { id: string; @@ -13,10 +13,10 @@ export interface RuleModule { description: string; category: string; language: string; - defaultSeverity: 'critical' | 'high' | 'medium' | 'low' | 'info'; + defaultSeverity: "critical" | "high" | "medium" | "low" | "info"; defaultParameters?: Record; dependencies?: string[]; - + // Rule implementation create(config?: RuleConfiguration): RuleInstance; } @@ -65,15 +65,17 @@ export class RuleLoader { } // Find specific version or default to latest - let module = modules.find(m => m.version === config.version); - + let module = modules.find((m) => m.version === config.version); + if (!module && !config.version) { // Fallback to latest version if no version specified module = modules.sort((a, b) => b.version.localeCompare(a.version))[0]; } if (!module) { - console.warn(`Rule version ${config.version} not found for ${config.id}`); + console.warn( + `Rule version ${config.version} not found for ${config.id}`, + ); return null; } @@ -87,7 +89,7 @@ export class RuleLoader { // Create new instance const instance = module.create(config); this.loadedRules.set(instanceId, instance); - + console.log(`Loaded rule: ${instanceId}`); return instance; } catch (error) { @@ -149,7 +151,7 @@ export class RuleLoader { */ async loadRules(configs: RuleConfiguration[]): Promise { const instances: RuleInstance[] = []; - + for (const config of configs) { if (config.enabled) { const instance = await this.loadRule(config); @@ -158,7 +160,7 @@ export class RuleLoader { } } } - + return instances; } @@ -166,8 +168,8 @@ export class RuleLoader { * Unload all rules */ async unloadAllRules(): Promise { - const unloadPromises = Array.from(this.loadedRules.keys()).map(ruleId => - this.unloadRule(ruleId) + const unloadPromises = Array.from(this.loadedRules.keys()).map((ruleId) => + this.unloadRule(ruleId), ); await Promise.all(unloadPromises); } @@ -179,7 +181,7 @@ export class RuleLoader { const modules = this.ruleModules.get(ruleId); if (!modules) return undefined; if (version) { - return modules.find(m => m.version === version); + return modules.find((m) => m.version === version); } // Return latest if version not specified return modules.sort((a, b) => b.version.localeCompare(a.version))[0]; @@ -199,23 +201,23 @@ export class RuleLoader { const errors: string[] = []; if (!module.id) { - errors.push('Rule module ID is required'); + errors.push("Rule module ID is required"); } if (!module.name) { - errors.push('Rule module name is required'); + errors.push("Rule module name is required"); } if (!module.category) { - errors.push('Rule module category is required'); + errors.push("Rule module category is required"); } if (!module.language) { - errors.push('Rule module language is required'); + errors.push("Rule module language is required"); } - if (!module.create || typeof module.create !== 'function') { - errors.push('Rule module must have a create function'); + if (!module.create || typeof module.create !== "function") { + errors.push("Rule module must have a create function"); } return { @@ -231,7 +233,7 @@ export class RuleLoader { // This would typically scan a directory for rule modules // For now, it's a placeholder for the implementation console.log(`Auto-discovering rule modules in: ${directory}`); - + // Example of what this would do: // 1. Scan directory for rule files // 2. Import each rule module @@ -251,14 +253,16 @@ export class RuleLoader { const rulesByLanguage: Record = {}; for (const instance of this.loadedRules.values()) { - // Try to find the module. We might need to parse the version from somewhere + // Try to find the module. We might need to parse the version from somewhere // or store the module reference in the instance. // For now, we search all modules for this ID. const modules = this.ruleModules.get(instance.id); const module = modules?.[0]; // Best effort: use first version for stats if we can't distinguish if (module) { - rulesByCategory[module.category] = (rulesByCategory[module.category] || 0) + 1; - rulesByLanguage[module.language] = (rulesByLanguage[module.language] || 0) + 1; + rulesByCategory[module.category] = + (rulesByCategory[module.category] || 0) + 1; + rulesByLanguage[module.language] = + (rulesByLanguage[module.language] || 0) + 1; } } diff --git a/packages/gas-estimator/stellar/__tests__/fee-estimator.spec.ts b/packages/gas-estimator/stellar/__tests__/fee-estimator.spec.ts new file mode 100644 index 0000000..3b479c9 --- /dev/null +++ b/packages/gas-estimator/stellar/__tests__/fee-estimator.spec.ts @@ -0,0 +1,724 @@ +/** + * Unit Tests for Stellar Fee Estimation Engine + * + * Validates fee estimation accuracy, simulation-based calculations, + * and acceptance criteria. + */ + +import { StellarFeeEstimator, DEFAULT_NETWORK_CONFIGS } from "../fee-estimator"; +import { + SimulationResult, + StellarNetworkConfig, + FeeEstimationResult, +} from "../types"; + +describe("StellarFeeEstimator", () => { + let estimator: StellarFeeEstimator; + let testnetConfig: StellarNetworkConfig; + + beforeEach(() => { + estimator = new StellarFeeEstimator(); + testnetConfig = DEFAULT_NETWORK_CONFIGS["soroban-testnet"]; + }); + + /** + * Helper to create a sample simulation result + */ + const createSampleSimulation = ( + overrides: Partial = {}, + ): SimulationResult => ({ + instructions: 1_250_000, + memoryBytes: 2_097_152, // 2 MB + resources: { + footprint: { + readOnly: [ + "ContractData(token_balance_alice)", + "ContractData(token_metadata)", + ], + readWrite: ["ContractData(token_balance_bob)"], + }, + instructions: 1_250_000, + readBytes: 512, + writeBytes: 256, + }, + transactionSizeBytes: 4096, + ...overrides, + }); + + describe("Basic Fee Estimation", () => { + it("should estimate fees from simulation results", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result).toBeDefined(); + expect(result.chainId).toBe("soroban-testnet"); + expect(result.totalFeeStroops).toBeGreaterThan(0); + expect(result.totalFeeXLM).toBeGreaterThan(0); + }); + + it("should calculate correct CPU cost", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // CPU fee = ⌈1,250,000 / 10,000⌉ × 100 = 125 × 100 = 12,500 stroops + expect(result.cpuCost.fee).toBe(12500); + expect(result.cpuCost.normalized).toBe(1_250_000 / 100_000_000); + expect(result.cpuCost.total).toBeGreaterThanOrEqual(result.cpuCost.fee); + }); + + it("should calculate correct ledger cost", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // Expected ledger fees: + // Reads: 2 entries × 1000 + ⌈512/1024⌉ × 500 = 2000 + 500 = 2500 + // Writes: 1 entry × 2000 + ⌈256/1024⌉ × 1000 = 2000 + 1000 = 3000 + // Bandwidth: ⌈4096/1024⌉ × 100 = 4 × 100 = 400 + // Total: 2500 + 3000 + 400 = 5900 stroops + expect(result.ledgerCost.fee).toBe(5900); + }); + + it("should convert stroops to XLM correctly", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // 1 XLM = 10^7 stroops + expect(result.totalFeeXLM).toBe(result.totalFeeStroops / 10_000_000); + }); + + it("should apply safety margin to total fees", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { safetyMargin: 1.15 }, + }); + + const baseFee = result.cpuCost.total + result.ledgerCost.fee; + const expectedWithMargin = Math.ceil(baseFee * 1.15); + + expect(result.totalFeeStroops).toBe(expectedWithMargin); + }); + }); + + describe("Priority-Based Estimation", () => { + it("should apply different multipliers for priority levels", async () => { + const simulation = createSampleSimulation(); + + const lowResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { priority: "low" }, + }); + + const normalResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { priority: "normal" }, + }); + + const highResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { priority: "high" }, + }); + + const criticalResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { priority: "critical" }, + }); + + // Fees should increase with priority + expect(lowResult.totalFeeStroops).toBeLessThan( + normalResult.totalFeeStroops, + ); + expect(normalResult.totalFeeStroops).toBeLessThan( + highResult.totalFeeStroops, + ); + expect(highResult.totalFeeStroops).toBeLessThan( + criticalResult.totalFeeStroops, + ); + }); + + it("should use normal priority by default", async () => { + const simulation = createSampleSimulation(); + + const defaultResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + const normalResult = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { priority: "normal" }, + }); + + expect(defaultResult.totalFeeStroops).toBe(normalResult.totalFeeStroops); + }); + }); + + describe("Efficiency Scoring", () => { + it("should calculate efficiency scores", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.scores).toBeDefined(); + expect(result.scores.cpu).toBeGreaterThanOrEqual(0); + expect(result.scores.cpu).toBeLessThanOrEqual(100); + expect(result.scores.memory).toBeGreaterThanOrEqual(0); + expect(result.scores.memory).toBeLessThanOrEqual(100); + expect(result.scores.ledger).toBeGreaterThanOrEqual(0); + expect(result.scores.ledger).toBeLessThanOrEqual(100); + expect(result.scores.total).toBeGreaterThanOrEqual(0); + expect(result.scores.total).toBeLessThanOrEqual(100); + }); + + it("should give excellent scores for low resource usage", async () => { + const simulation = createSampleSimulation({ + instructions: 1_000_000, // 1% of limit + memoryBytes: 1_000_000, // ~2.4% of limit + resources: { + footprint: { + readOnly: ["ContractData(balance)"], + readWrite: [], + }, + instructions: 1_000_000, + readBytes: 256, + writeBytes: 0, + }, + transactionSizeBytes: 2048, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // Low usage should result in high scores + expect(result.scores.cpu).toBeGreaterThan(80); + expect(result.scores.ledger).toBeGreaterThan(80); + }); + + it("should give poor scores for high resource usage", async () => { + const simulation = createSampleSimulation({ + instructions: 85_000_000, // 85% of limit + memoryBytes: 35_000_000, // ~83% of limit + resources: { + footprint: { + readOnly: Array(30).fill("ContractData(entry)"), + readWrite: Array(20).fill("ContractData(entry)"), + }, + instructions: 85_000_000, + readBytes: 180_000, + writeBytes: 90_000, + }, + transactionSizeBytes: 90_000, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // High usage should result in low scores + expect(result.scores.cpu).toBeLessThan(50); + expect(result.scores.ledger).toBeLessThan(50); + }); + }); + + describe("Optimization Hints", () => { + it("should generate hints for high CPU usage", async () => { + const simulation = createSampleSimulation({ + instructions: 85_000_000, // 85% of limit + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.optimizationHints.length).toBeGreaterThan(0); + expect( + result.optimizationHints.some((h: string) => h.includes("CPU")), + ).toBeTruthy(); + }); + + it("should generate hints for high memory usage", async () => { + const simulation = createSampleSimulation({ + memoryBytes: 35_000_000, // ~83% of limit + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.optimizationHints.length).toBeGreaterThan(0); + expect( + result.optimizationHints.some( + (h: string) => h.includes("Memory") || h.includes("memory"), + ), + ).toBeTruthy(); + }); + + it("should generate hints for high ledger usage", async () => { + const simulation = createSampleSimulation({ + resources: { + footprint: { + readOnly: Array(32).fill("ContractData(entry)"), // 80% of limit + readWrite: Array(20).fill("ContractData(entry)"), // 80% of limit + }, + instructions: 1_250_000, + readBytes: 180_000, + writeBytes: 90_000, + }, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.optimizationHints.length).toBeGreaterThan(0); + expect( + result.optimizationHints.some( + (h: string) => + h.includes("read") || h.includes("write") || h.includes("ledger"), + ), + ).toBeTruthy(); + }); + + it("should show excellent message for efficient contracts", async () => { + const simulation = createSampleSimulation({ + instructions: 500_000, // 0.5% of limit + memoryBytes: 500_000, // ~1.2% of limit + resources: { + footprint: { + readOnly: ["ContractData(balance)"], + readWrite: [], + }, + instructions: 500_000, + readBytes: 128, + writeBytes: 0, + }, + transactionSizeBytes: 1024, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.optimizationHints).toContain( + "✅ Excellent resource efficiency!", + ); + }); + + it("should not generate hints when disabled", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { includeHints: false }, + }); + + expect(result.optimizationHints).toEqual([]); + }); + }); + + describe("Safety Violations", () => { + it("should detect CPU safety violations", async () => { + const simulation = createSampleSimulation({ + instructions: 96_000_000, // 96% of limit + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.safetyViolations.length).toBeGreaterThan(0); + expect( + result.safetyViolations.some((v: string) => v.includes("CPU")), + ).toBeTruthy(); + }); + + it("should detect memory safety violations", async () => { + const simulation = createSampleSimulation({ + memoryBytes: 40_000_000, // ~95.4% of limit + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.safetyViolations.length).toBeGreaterThan(0); + expect( + result.safetyViolations.some((v: string) => v.includes("Memory")), + ).toBeTruthy(); + }); + + it("should detect ledger safety violations", async () => { + const simulation = createSampleSimulation({ + resources: { + footprint: { + readOnly: Array(39).fill("ContractData(entry)"), // 97.5% of limit + readWrite: [], + }, + instructions: 1_250_000, + readBytes: 195_000, // 97.5% of limit + writeBytes: 0, + }, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.safetyViolations.length).toBeGreaterThan(0); + expect( + result.safetyViolations.some((v: string) => v.includes("read")), + ).toBeTruthy(); + }); + + it("should not check safety violations when disabled", async () => { + const simulation = createSampleSimulation({ + instructions: 96_000_000, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + options: { includeSafetyChecks: false }, + }); + + expect(result.safetyViolations).toEqual([]); + }); + }); + + describe("Confidence Calculation", () => { + it("should give high confidence for normal usage", async () => { + const simulation = createSampleSimulation(); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.confidence).toBeGreaterThanOrEqual(80); + expect(result.confidence).toBeLessThanOrEqual(100); + }); + + it("should reduce confidence for high resource usage", async () => { + const simulation = createSampleSimulation({ + instructions: 85_000_000, + memoryBytes: 35_000_000, + resources: { + footprint: { + readOnly: Array(30).fill("ContractData(entry)"), + readWrite: Array(20).fill("ContractData(entry)"), + }, + instructions: 85_000_000, + readBytes: 180_000, + writeBytes: 90_000, + }, + transactionSizeBytes: 90_000, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.confidence).toBeLessThan(90); + }); + }); + + describe("Contract Simulation", () => { + it("should estimate fees from contract simulation", async () => { + // Mock simulation function + const mockSimulate = jest + .fn() + .mockResolvedValue(createSampleSimulation()); + + const result = await estimator.estimateFromContract( + { + contractCode: "wasm_code_here", + method: "transfer", + params: ["alice", "bob", 1000], + networkConfig: testnetConfig, + }, + mockSimulate, + ); + + expect(mockSimulate).toHaveBeenCalledWith("wasm_code_here", "transfer", [ + "alice", + "bob", + 1000, + ]); + expect(result.totalFeeStroops).toBeGreaterThan(0); + }); + + it("should throw error when simulation reverts", async () => { + const mockSimulate = jest.fn().mockResolvedValue({ + ...createSampleSimulation(), + reverted: true, + error: "Insufficient balance", + }); + + await expect( + estimator.estimateFromContract( + { + contractCode: "wasm_code_here", + method: "transfer", + params: ["alice", "bob", 1000], + networkConfig: testnetConfig, + }, + mockSimulate, + ), + ).rejects.toThrow("Simulation reverted: Insufficient balance"); + }); + + it("should throw error when contract code is missing", async () => { + const mockSimulate = jest.fn(); + + await expect( + estimator.estimateFromContract( + { + method: "transfer", + params: ["alice", "bob", 1000], + networkConfig: testnetConfig, + } as any, + mockSimulate, + ), + ).rejects.toThrow("Contract code is required for simulation"); + }); + }); + + describe("Network Configuration", () => { + it("should get default config for known chains", () => { + const mainnetConfig = + StellarFeeEstimator.getDefaultConfig("soroban-mainnet"); + expect(mainnetConfig.chainId).toBe("soroban-mainnet"); + expect(mainnetConfig.txMaxInstructions).toBe(100_000_000); + + const testnetConfig = + StellarFeeEstimator.getDefaultConfig("soroban-testnet"); + expect(testnetConfig.chainId).toBe("soroban-testnet"); + }); + + it("should throw error for unknown chains", () => { + expect(() => { + StellarFeeEstimator.getDefaultConfig("unknown-chain"); + }).toThrow("No default configuration for chain: unknown-chain"); + }); + + it("should register custom network configurations", () => { + const customConfig: StellarNetworkConfig = { + chainId: "soroban-custom", + chainName: "Custom Network", + txMaxInstructions: 50_000_000, + ledgerMaxInstructions: 500_000_000, + feeRatePerInstructionsIncrement: 5000, + feeCPUPerIncrement: 50, + txMemoryLimit: 20_971_520, + txMaxReadLedgerEntries: 20, + txMaxReadBytes: 100_000, + txMaxWriteLedgerEntries: 15, + txMaxWriteBytes: 50_000, + feeReadLedgerEntry: 500, + feeWriteLedgerEntry: 1000, + feeRead1KB: 250, + feeWrite1KB: 500, + txMaxSizeBytes: 50_000, + feeTxSize1KB: 50, + version: "custom-v1", + }; + + StellarFeeEstimator.registerNetworkConfig(customConfig); + + const retrieved = StellarFeeEstimator.getDefaultConfig("soroban-custom"); + expect(retrieved.chainId).toBe("soroban-custom"); + expect(retrieved.txMaxInstructions).toBe(50_000_000); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero instructions", async () => { + const simulation = createSampleSimulation({ + instructions: 0, + resources: { + footprint: { + readOnly: [], + readWrite: [], + }, + instructions: 0, + readBytes: 0, + writeBytes: 0, + }, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.cpuCost.fee).toBe(0); + expect(result.totalFeeStroops).toBeGreaterThanOrEqual(0); + }); + + it("should handle minimal transaction", async () => { + const simulation = createSampleSimulation({ + instructions: 1000, + memoryBytes: 1024, + resources: { + footprint: { + readOnly: ["ContractData(minimal)"], + readWrite: [], + }, + instructions: 1000, + readBytes: 64, + writeBytes: 0, + }, + transactionSizeBytes: 512, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.totalFeeStroops).toBeGreaterThan(0); + expect(result.scores.total).toBeGreaterThan(0); + }); + + it("should handle maximum resource usage", async () => { + const simulation = createSampleSimulation({ + instructions: 99_000_000, + memoryBytes: 41_000_000, + resources: { + footprint: { + readOnly: Array(38).fill("ContractData(entry)"), + readWrite: Array(24).fill("ContractData(entry)"), + }, + instructions: 99_000_000, + readBytes: 199_000, + writeBytes: 99_000, + }, + transactionSizeBytes: 99_000, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.totalFeeStroops).toBeGreaterThan(0); + expect(result.safetyViolations.length).toBeGreaterThan(0); + }); + }); + + describe("Real-World Scenarios", () => { + it("should estimate simple token transfer correctly", async () => { + // Typical token transfer simulation + const simulation = createSampleSimulation({ + instructions: 1_250_000, + memoryBytes: 2_097_152, + resources: { + footprint: { + readOnly: [ + "ContractData(token_balance_alice)", + "ContractData(token_metadata)", + ], + readWrite: ["ContractData(token_balance_bob)"], + }, + instructions: 1_250_000, + readBytes: 512, + writeBytes: 256, + }, + transactionSizeBytes: 4096, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + // Validate expected costs + expect(result.cpuCost.fee).toBe(12500); // ⌈1,250,000/10,000⌉ × 100 + expect(result.ledgerCost.fee).toBe(5900); // 2500 + 3000 + 400 + + // Total should include safety margin + const baseFee = result.cpuCost.total + result.ledgerCost.fee; + expect(result.totalFeeStroops).toBe(Math.ceil(baseFee * 1.15)); + + // Should have excellent efficiency + expect(result.scores.total).toBeGreaterThan(80); + expect(result.optimizationHints).toContain( + "✅ Excellent resource efficiency!", + ); + }); + + it("should estimate batch operation correctly", async () => { + // Batch transfer to multiple recipients + const simulation = createSampleSimulation({ + instructions: 5_000_000, + memoryBytes: 8_000_000, + resources: { + footprint: { + readOnly: [ + "ContractData(token_metadata)", + "ContractData(sender_balance)", + ], + readWrite: [ + "ContractData(recipient1_balance)", + "ContractData(recipient2_balance)", + "ContractData(recipient3_balance)", + "ContractData(recipient4_balance)", + "ContractData(recipient5_balance)", + ], + }, + instructions: 5_000_000, + readBytes: 1024, + writeBytes: 2560, + }, + transactionSizeBytes: 8192, + }); + + const result = await estimator.estimateFromSimulation({ + simulation, + networkConfig: testnetConfig, + }); + + expect(result.cpuCost.fee).toBe(50000); // ⌈5,000,000/10,000⌉ × 100 + expect(result.ledgerCost.fee).toBeGreaterThan(0); + expect(result.scores.total).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/gas-estimator/stellar/fee-estimator.ts b/packages/gas-estimator/stellar/fee-estimator.ts new file mode 100644 index 0000000..37f46ff --- /dev/null +++ b/packages/gas-estimator/stellar/fee-estimator.ts @@ -0,0 +1,569 @@ +/** + * Stellar Fee Estimation Engine + * + * Estimates Stellar smart contract execution fees based on simulation results + * and network configuration. Implements the cost model specified in + * docs/soroban-cost-model-spec.md + */ + +import { + StellarNetworkConfig, + SimulationResult, + CPUCost, + MemoryCost, + LedgerCost, + FeeEstimationResult, + EfficiencyScores, + FeeEstimationOptions, + DirectFeeRequest, + SimulationFeeRequest, +} from "./types"; + +/** + * Default network configurations for common Stellar networks + */ +export const DEFAULT_NETWORK_CONFIGS: Record = { + "soroban-mainnet": { + chainId: "soroban-mainnet", + chainName: "Soroban Mainnet", + txMaxInstructions: 100_000_000, + ledgerMaxInstructions: 1_000_000_000, + feeRatePerInstructionsIncrement: 10_000, + feeCPUPerIncrement: 100, // stroops (0.00001 XLM) + txMemoryLimit: 41_943_040, // 40 MB + txMaxReadLedgerEntries: 40, + txMaxReadBytes: 200_000, + txMaxWriteLedgerEntries: 25, + txMaxWriteBytes: 100_000, + feeReadLedgerEntry: 1000, // stroops (0.0001 XLM) + feeWriteLedgerEntry: 2000, // stroops (0.0002 XLM) + feeRead1KB: 500, // stroops (0.00005 XLM) + feeWrite1KB: 1000, // stroops (0.0001 XLM) + txMaxSizeBytes: 100_000, + feeTxSize1KB: 100, // stroops (0.00001 XLM) + version: "mainnet-v20", + }, + "soroban-testnet": { + chainId: "soroban-testnet", + chainName: "Soroban Testnet", + txMaxInstructions: 100_000_000, + ledgerMaxInstructions: 1_000_000_000, + feeRatePerInstructionsIncrement: 10_000, + feeCPUPerIncrement: 100, + txMemoryLimit: 41_943_040, + txMaxReadLedgerEntries: 40, + txMaxReadBytes: 200_000, + txMaxWriteLedgerEntries: 25, + txMaxWriteBytes: 100_000, + feeReadLedgerEntry: 1000, + feeWriteLedgerEntry: 2000, + feeRead1KB: 500, + feeWrite1KB: 1000, + txMaxSizeBytes: 100_000, + feeTxSize1KB: 100, + version: "testnet-v20", + }, +}; + +/** + * Score weights for different resource dimensions + */ +const SCORE_WEIGHTS = { + cpu: 0.4, + memory: 0.2, + ledger: 0.4, +}; + +/** + * Ledger utilization weights + */ +const LEDGER_WEIGHTS = [0.2, 0.2, 0.2, 0.2, 0.2]; // r_entries, r_bytes, w_entries, w_bytes, bw + +/** + * Memory scaling factor for penalty calculation + */ +const SCALING_FACTOR_MEMORY = 100; + +/** + * Priority multipliers for safety margins + */ +const PRIORITY_MULTIPLIERS = { + low: 1.0, + normal: 1.15, + high: 1.3, + critical: 1.5, +}; + +/** + * Stellar Fee Estimation Engine + * + * Provides accurate execution cost predictions for Stellar smart contracts + * using simulation-based calculations. + */ +export class StellarFeeEstimator { + private defaultOptions: Required; + + constructor(options?: FeeEstimationOptions) { + this.defaultOptions = { + priority: options?.priority || "normal", + safetyMargin: options?.safetyMargin || 1.15, + includeHints: options?.includeHints ?? true, + includeSafetyChecks: options?.includeSafetyChecks ?? true, + }; + } + + /** + * Estimate fees from simulation results + * + * @param request - Direct fee estimation request with simulation results + * @returns Promise resolving to fee estimation result + */ + async estimateFromSimulation( + request: DirectFeeRequest, + ): Promise { + const { simulation, networkConfig, options } = request; + const mergedOptions = { ...this.defaultOptions, ...options }; + + // Validate simulation results + if (simulation.reverted) { + throw new Error( + `Simulation reverted: ${simulation.error || "Unknown error"}`, + ); + } + + // Calculate costs for each dimension + const cpuCost = this.computeCPUCost(simulation, networkConfig); + const memoryCost = this.computeMemoryCost(simulation, networkConfig); + const ledgerCost = this.computeLedgerCost(simulation, networkConfig); + + // Apply safety margin based on priority + const priorityMultiplier = PRIORITY_MULTIPLIERS[mergedOptions.priority]; + const safetyMargin = mergedOptions.safetyMargin * priorityMultiplier; + + // Calculate total fees with safety margin + const baseTotalFee = cpuCost.total + ledgerCost.fee; + const totalFeeStroops = Math.ceil(baseTotalFee * safetyMargin); + const totalFeeXLM = totalFeeStroops / 10_000_000; // 1 XLM = 10^7 stroops + + // Compute efficiency scores + const scores = this.computeScores(cpuCost, memoryCost, ledgerCost); + + // Generate optimization hints + const optimizationHints = mergedOptions.includeHints + ? this.generateHints(cpuCost, memoryCost, ledgerCost) + : []; + + // Check safety violations + const safetyViolations = mergedOptions.includeSafetyChecks + ? this.checkSafetyViolations(cpuCost, memoryCost, ledgerCost) + : []; + + // Calculate confidence based on simulation quality + const confidence = this.calculateConfidence(simulation, networkConfig); + + return { + chainId: networkConfig.chainId, + timestamp: Date.now(), + cpuCost, + memoryCost, + ledgerCost, + totalFeeStroops, + totalFeeXLM, + scores, + optimizationHints, + safetyViolations, + confidence, + }; + } + + /** + * Estimate fees by simulating a contract method + * + * Note: This requires an actual RPC connection to simulate transactions. + * In production, this would call the Soroban RPC simulateTransaction endpoint. + * + * @param request - Simulation fee request + * @param simulateFn - Function to perform simulation (injected for testing) + * @returns Promise resolving to fee estimation result + */ + async estimateFromContract( + request: SimulationFeeRequest, + simulateFn: ( + contractCode: string, + method: string, + params: any[], + ) => Promise, + ): Promise { + const { contractCode, method, params, networkConfig, options } = request; + + if (!contractCode) { + throw new Error("Contract code is required for simulation"); + } + + // Simulate the contract method + const simulation = await simulateFn(contractCode, method, params); + + // Estimate fees from simulation results + return this.estimateFromSimulation({ + simulation, + networkConfig, + options, + }); + } + + /** + * Compute CPU cost from instruction count + * + * Formula: C_cpu_fee = ⌈I / R_cpu⌉ × F_cpu + * With ledger pressure penalty: C_cpu = C_cpu_fee × (1 + w_ledger × P_cpu_ledger) + */ + private computeCPUCost( + simulation: SimulationResult, + config: StellarNetworkConfig, + ): CPUCost { + const instructions = simulation.instructions; + const limitTx = config.txMaxInstructions; + const limitLedger = config.ledgerMaxInstructions; + + // Fee calculation + const feeIncrements = Math.ceil( + instructions / config.feeRatePerInstructionsIncrement, + ); + const fee = feeIncrements * config.feeCPUPerIncrement; + + // Utilization calculations + const utilTx = instructions / limitTx; + const utilLedger = instructions / limitLedger; + + // Quadratic penalty for disproportionate ledger usage + const ledgerPressure = Math.pow(utilLedger, 2); + + // Final cost adjusted for pressure (w_ledger = 0.5) + const pressureWeight = 0.5; + const totalCost = fee * (1 + pressureWeight * ledgerPressure); + + return { + fee, + normalized: utilTx, + ledgerPressure, + total: totalCost, + }; + } + + /** + * Compute memory cost from peak memory usage + * + * Note: Soroban doesn't charge direct fees for memory, but we calculate + * a penalty score to discourage approaching the hard limit. + * Formula: cost = k * e^(5 * utilization) + */ + private computeMemoryCost( + simulation: SimulationResult, + config: StellarNetworkConfig, + ): MemoryCost { + const memUsed = simulation.memoryBytes; + const limit = config.txMemoryLimit; + + const utilization = memUsed / limit; + + // Exponential penalty prevents approaching hard limit + const costScore = SCALING_FACTOR_MEMORY * Math.exp(5 * utilization); + + return { + bytesUsed: memUsed, + normalized: utilization, + costScore, + }; + } + + /** + * Compute ledger I/O and bandwidth cost + * + * Includes fees for: + * - Read/write ledger entries + * - Read/write byte volume + * - Transaction bandwidth + */ + private computeLedgerCost( + simulation: SimulationResult, + config: StellarNetworkConfig, + ): LedgerCost { + const { footprint, readBytes, writeBytes } = simulation.resources; + const txSize = simulation.transactionSizeBytes; + + // Count reads and writes + const reads = footprint.readOnly.length; + const writes = footprint.readWrite.length; + + // Fee components + const costReads = + reads * config.feeReadLedgerEntry + + Math.ceil(readBytes / 1024) * config.feeRead1KB; + + const costWrites = + writes * config.feeWriteLedgerEntry + + Math.ceil(writeBytes / 1024) * config.feeWrite1KB; + + const costBandwidth = Math.ceil(txSize / 1024) * config.feeTxSize1KB; + + const totalFee = costReads + costWrites + costBandwidth; + + // Normalized utilization per dimension + const breakdown = { + readEntries: reads / config.txMaxReadLedgerEntries, + readBytes: readBytes / config.txMaxReadBytes, + writeEntries: writes / config.txMaxWriteLedgerEntries, + writeBytes: writeBytes / config.txMaxWriteBytes, + bandwidth: txSize / config.txMaxSizeBytes, + }; + + // Composite score + const compositeNorm = + breakdown.readEntries * LEDGER_WEIGHTS[0] + + breakdown.readBytes * LEDGER_WEIGHTS[1] + + breakdown.writeEntries * LEDGER_WEIGHTS[2] + + breakdown.writeBytes * LEDGER_WEIGHTS[3] + + breakdown.bandwidth * LEDGER_WEIGHTS[4]; + + return { + fee: totalFee, + normalized: compositeNorm, + breakdown, + }; + } + + /** + * Compute per-dimension and aggregate efficiency scores + * + * Scoring bands: + * - Excellent (100-80): 0% -> 50% utilization + * - Good (80-50): 50% -> 80% utilization + * - Poor (50-0): 80% -> 100% utilization + */ + private computeScores( + cpu: CPUCost, + mem: MemoryCost, + ledger: LedgerCost, + ): EfficiencyScores { + const scoreCPU = this.scoreDimension(cpu.normalized); + const scoreMem = this.scoreDimension(mem.normalized); + const scoreLedger = this.scoreDimension(ledger.normalized); + + const scoreTotal = Math.round( + SCORE_WEIGHTS.cpu * scoreCPU + + SCORE_WEIGHTS.memory * scoreMem + + SCORE_WEIGHTS.ledger * scoreLedger, + ); + + return { + cpu: scoreCPU, + memory: scoreMem, + ledger: scoreLedger, + total: scoreTotal, + }; + } + + /** + * Convert utilization (0-1) to score (100-0) + */ + private scoreDimension( + utilization: number, + thresholdLow: number = 0.5, + thresholdHigh: number = 0.8, + ): number { + let score: number; + + if (utilization < thresholdLow) { + // Excellent band: 100 -> 80 + score = this.interpolate(utilization, 0, thresholdLow, 100, 80); + } else if (utilization < thresholdHigh) { + // Good band: 80 -> 50 + score = this.interpolate( + utilization, + thresholdLow, + thresholdHigh, + 80, + 50, + ); + } else { + // Poor band: 50 -> 0 + score = this.interpolate(utilization, thresholdHigh, 1.0, 50, 0); + } + + return Math.max(0, Math.min(100, Math.round(score))); + } + + /** + * Linear interpolation helper + */ + private interpolate( + val: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number, + ): number { + if (inMax === inMin) { + return outMin; + } + const ratio = (val - inMin) / (inMax - inMin); + return outMin + ratio * (outMax - outMin); + } + + /** + * Generate actionable optimization hints based on resource usage + */ + private generateHints( + cpu: CPUCost, + mem: MemoryCost, + ledger: LedgerCost, + ): string[] { + const hints: string[] = []; + + // CPU hints + if (cpu.normalized > 0.8) { + hints.push( + `CRITICAL: CPU usage at ${(cpu.normalized * 100).toFixed(0)}%. Reduce instruction count.`, + ); + } else if (cpu.normalized > 0.6) { + hints.push("HIGH CPU: Optimize hot loops and host function calls."); + } + + if (cpu.ledgerPressure > 0.5) { + const pressurePct = Math.sqrt(cpu.ledgerPressure); + hints.push( + `High ledger CPU pressure (${(pressurePct * 100).toFixed(1)}%).`, + ); + } + + // Memory hints + if (mem.normalized > 0.7) { + hints.push( + `CRITICAL: Memory usage at ${(mem.normalized * 100).toFixed(0)}%. Optimize allocations.`, + ); + } else if (mem.normalized > 0.5) { + hints.push("MODERATE memory usage. Review data structure sizes."); + } + + // Ledger hints + const ledgerLabels: Record = { + readEntries: "read entry count", + writeEntries: "write entry count", + readBytes: "read byte volume", + writeBytes: "write byte volume", + bandwidth: "transaction size", + }; + + const ledgerEntries = Object.entries(ledger.breakdown); + for (const [key, util] of ledgerEntries) { + if (util > 0.75) { + hints.push( + `HIGH ${ledgerLabels[key]}. Consider batching or compression.`, + ); + } + } + + // Cross-dimension hints + if (cpu.normalized > 0.7 && ledger.breakdown.readEntries > 0.5) { + hints.push( + "TIP: High CPU + reads. Check for redundant storage accesses.", + ); + } + + if (hints.length === 0) { + hints.push("✅ Excellent resource efficiency!"); + } + + return hints; + } + + /** + * Check for safety violations (95% hard limit threshold) + */ + private checkSafetyViolations( + cpu: CPUCost, + mem: MemoryCost, + ledger: LedgerCost, + ): string[] { + const violations: string[] = []; + + if (cpu.normalized > 0.95) { + violations.push("CPU exceeds 95% safety margin"); + } + + if (mem.normalized > 0.95) { + violations.push("Memory exceeds 95% safety margin"); + } + + if (ledger.breakdown.readEntries > 0.95) { + violations.push("Ledger read entries exceed 95% safety margin"); + } + + if (ledger.breakdown.writeEntries > 0.95) { + violations.push("Ledger write entries exceed 95% safety margin"); + } + + if (ledger.breakdown.readBytes > 0.95) { + violations.push("Ledger read bytes exceed 95% safety margin"); + } + + if (ledger.breakdown.writeBytes > 0.95) { + violations.push("Ledger write bytes exceed 95% safety margin"); + } + + if (ledger.breakdown.bandwidth > 0.95) { + violations.push("Transaction bandwidth exceeds 95% safety margin"); + } + + return violations; + } + + /** + * Calculate estimation confidence based on simulation quality + */ + private calculateConfidence( + simulation: SimulationResult, + config: StellarNetworkConfig, + ): number { + let confidence = 100; + + // Reduce confidence if approaching limits + if (simulation.instructions / config.txMaxInstructions > 0.8) { + confidence -= 10; + } + + if (simulation.memoryBytes / config.txMemoryLimit > 0.8) { + confidence -= 10; + } + + // Reduce confidence for high ledger utilization + const ledgerUtilization = + (simulation.resources.readBytes / config.txMaxReadBytes + + simulation.resources.writeBytes / config.txMaxWriteBytes) / + 2; + + if (ledgerUtilization > 0.8) { + confidence -= 10; + } + + return Math.max(0, Math.min(100, confidence)); + } + + /** + * Get default network configuration for a chain ID + */ + static getDefaultConfig(chainId: string): StellarNetworkConfig { + const config = DEFAULT_NETWORK_CONFIGS[chainId]; + if (!config) { + throw new Error(`No default configuration for chain: ${chainId}`); + } + return config; + } + + /** + * Register a custom network configuration + */ + static registerNetworkConfig(config: StellarNetworkConfig): void { + DEFAULT_NETWORK_CONFIGS[config.chainId] = config; + } +} diff --git a/packages/gas-estimator/stellar/index.ts b/packages/gas-estimator/stellar/index.ts new file mode 100644 index 0000000..ce7f0f8 --- /dev/null +++ b/packages/gas-estimator/stellar/index.ts @@ -0,0 +1,22 @@ +/** + * Stellar Fee Estimation Engine + * + * Export public API for estimating Stellar smart contract execution fees + */ + +export { StellarFeeEstimator, DEFAULT_NETWORK_CONFIGS } from "./fee-estimator"; +export type { + StellarNetworkConfig, + LedgerFootprint, + TransactionResources, + SimulationResult, + CPUCost, + MemoryCost, + LedgerCost, + FeeEstimationResult, + EfficiencyScores, + FeePriority, + FeeEstimationOptions, + SimulationFeeRequest, + DirectFeeRequest, +} from "./types"; diff --git a/packages/gas-estimator/stellar/package-lock.json b/packages/gas-estimator/stellar/package-lock.json new file mode 100644 index 0000000..d60704c --- /dev/null +++ b/packages/gas-estimator/stellar/package-lock.json @@ -0,0 +1,3861 @@ +{ + "name": "@gasguard/stellar-fee-estimator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gasguard/stellar-fee-estimator", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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/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/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "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/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "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", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "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", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.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/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "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/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "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/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/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/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "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", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/gas-estimator/stellar/package.json b/packages/gas-estimator/stellar/package.json new file mode 100644 index 0000000..4be40ff --- /dev/null +++ b/packages/gas-estimator/stellar/package.json @@ -0,0 +1,55 @@ +{ + "name": "@gasguard/stellar-fee-estimator", + "version": "1.0.0", + "description": "Stellar Fee Estimation Engine - Estimates Stellar smart contract execution fees based on simulation results", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "lint": "eslint . --ext .ts", + "clean": "rm -rf dist" + }, + "keywords": [ + "stellar", + "soroban", + "fee-estimation", + "gas-estimator", + "smart-contracts", + "blockchain" + ], + "author": "GasGuard Engineering", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gasguard/gas-gaurd.git", + "directory": "packages/gas-estimator/stellar" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": [ + "" + ], + "testMatch": [ + "**/__tests__/**/*.spec.ts" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ] + } +} diff --git a/packages/gas-estimator/stellar/tsconfig.json b/packages/gas-estimator/stellar/tsconfig.json new file mode 100644 index 0000000..d511f28 --- /dev/null +++ b/packages/gas-estimator/stellar/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["*.ts", "**/*.ts"], + "exclude": ["node_modules", "dist", "**/__tests__/*"] +} diff --git a/packages/gas-estimator/stellar/types.ts b/packages/gas-estimator/stellar/types.ts new file mode 100644 index 0000000..22210a8 --- /dev/null +++ b/packages/gas-estimator/stellar/types.ts @@ -0,0 +1,246 @@ +/** + * Stellar Fee Estimation Engine Types + * + * Type definitions for estimating Stellar smart contract execution fees + * based on simulation results and network configuration. + */ + +/** + * Soroban network configuration for fee calculations + */ +export interface StellarNetworkConfig { + /** Chain identifier (e.g., "soroban-mainnet", "soroban-testnet") */ + chainId: string; + /** Network name */ + chainName: string; + /** RPC endpoint URL */ + rpcUrl?: string; + + // CPU/Compute configuration + /** Maximum instructions per transaction */ + txMaxInstructions: number; + /** Maximum instructions per ledger */ + ledgerMaxInstructions: number; + /** Fee rate per instruction increment */ + feeRatePerInstructionsIncrement: number; + /** Fee per CPU instruction increment (in stroops) */ + feeCPUPerIncrement: number; + /** Transaction memory limit in bytes */ + txMemoryLimit: number; + + // Ledger I/O configuration + /** Max read ledger entries per transaction */ + txMaxReadLedgerEntries: number; + /** Max read bytes per transaction */ + txMaxReadBytes: number; + /** Max write ledger entries per transaction */ + txMaxWriteLedgerEntries: number; + /** Max write bytes per transaction */ + txMaxWriteBytes: number; + + // Ledger I/O fees (in stroops) + /** Fee per read ledger entry */ + feeReadLedgerEntry: number; + /** Fee per write ledger entry */ + feeWriteLedgerEntry: number; + /** Fee per 1KB read */ + feeRead1KB: number; + /** Fee per 1KB write */ + feeWrite1KB: number; + + // Bandwidth configuration + /** Max transaction size in bytes */ + txMaxSizeBytes: number; + /** Fee per 1KB transaction size */ + feeTxSize1KB: number; + + /** Network version identifier */ + version?: string; +} + +/** + * Ledger entry footprint for a transaction + */ +export interface LedgerFootprint { + /** Read-only ledger entry keys */ + readOnly: string[]; + /** Read-write ledger entry keys */ + readWrite: string[]; +} + +/** + * Transaction resources from simulation + */ +export interface TransactionResources { + /** Ledger entry footprint */ + footprint: LedgerFootprint; + /** Number of instructions consumed */ + instructions: number; + /** Total bytes read */ + readBytes: number; + /** Total bytes written */ + writeBytes: number; +} + +/** + * Result from Soroban RPC simulateTransaction call + */ +export interface SimulationResult { + /** CPU instructions consumed */ + instructions: number; + /** Peak memory usage in bytes */ + memoryBytes: number; + /** Transaction resources */ + resources: TransactionResources; + /** Transaction size in bytes */ + transactionSizeBytes: number; + /** Whether the simulation reverted */ + reverted?: boolean; + /** Error message if simulation failed */ + error?: string; +} + +/** + * CPU cost breakdown + */ +export interface CPUCost { + /** Base fee in stroops */ + fee: number; + /** Normalized utilization [0, 1] */ + normalized: number; + /** Ledger pressure factor [0, 1] */ + ledgerPressure: number; + /** Total cost with pressure adjustment in stroops */ + total: number; +} + +/** + * Memory cost breakdown + */ +export interface MemoryCost { + /** Bytes used */ + bytesUsed: number; + /** Normalized utilization [0, 1] */ + normalized: number; + /** Penalty score (no direct fees in Soroban) */ + costScore: number; +} + +/** + * Ledger I/O and bandwidth cost breakdown + */ +export interface LedgerCost { + /** Total fee in stroops */ + fee: number; + /** Normalized utilization [0, 1] */ + normalized: number; + /** Utilization breakdown by dimension */ + breakdown: { + readEntries: number; + readBytes: number; + writeEntries: number; + writeBytes: number; + bandwidth: number; + }; +} + +/** + * Complete fee estimation result + */ +export interface FeeEstimationResult { + /** Chain ID */ + chainId: string; + /** Timestamp of estimation */ + timestamp: number; + + // Cost breakdowns + /** CPU cost details */ + cpuCost: CPUCost; + /** Memory cost details */ + memoryCost: MemoryCost; + /** Ledger I/O cost details */ + ledgerCost: LedgerCost; + + // Total fees + /** Total resource fee in stroops */ + totalFeeStroops: number; + /** Total resource fee in XLM */ + totalFeeXLM: number; + + // Efficiency scores + /** Resource efficiency scores */ + scores: EfficiencyScores; + + // Optimization hints + /** Actionable optimization suggestions */ + optimizationHints: string[]; + /** Safety violations if any */ + safetyViolations: string[]; + + // Confidence metrics + /** Estimation confidence percentage [0, 100] */ + confidence: number; +} + +/** + * Resource efficiency scores (0-100 scale) + */ +export interface EfficiencyScores { + /** CPU efficiency score */ + cpu: number; + /** Memory efficiency score */ + memory: number; + /** Ledger efficiency score */ + ledger: number; + /** Overall weighted score */ + total: number; +} + +/** + * Priority level for fee estimation + */ +export type FeePriority = "low" | "normal" | "high" | "critical"; + +/** + * Fee estimation options + */ +export interface FeeEstimationOptions { + /** Priority level (affects safety margins) */ + priority?: FeePriority; + /** Additional safety margin multiplier (default: 1.15) */ + safetyMargin?: number; + /** Include optimization hints */ + includeHints?: boolean; + /** Include safety checks */ + includeSafetyChecks?: boolean; +} + +/** + * Simulation-based fee estimation request + */ +export interface SimulationFeeRequest { + /** Contract ID (for context) */ + contractId?: string; + /** Method name to simulate */ + method: string; + /** Method parameters */ + params: any[]; + /** Contract WASM or code */ + contractCode?: string; + /** Network configuration */ + networkConfig: StellarNetworkConfig; + /** Estimation options */ + options?: FeeEstimationOptions; +} + +/** + * Direct fee estimation from simulation results + */ +export interface DirectFeeRequest { + /** Simulation results */ + simulation: SimulationResult; + /** Network configuration */ + networkConfig: StellarNetworkConfig; + /** Estimation options */ + options?: FeeEstimationOptions; +} diff --git a/packages/lsp/lsp-server.ts b/packages/lsp/lsp-server.ts index 09407a1..2136279 100644 --- a/packages/lsp/lsp-server.ts +++ b/packages/lsp/lsp-server.ts @@ -71,7 +71,7 @@ export class GasGuardLspServer { line: number, message: string, code: string, - severity: DiagnosticSeverity = 2 + severity: DiagnosticSeverity = 2, ): LspDiagnostic { return { range: { @@ -80,7 +80,7 @@ export class GasGuardLspServer { }, severity, code, - source: 'gasguard', + source: "gasguard", message, }; } diff --git a/packages/plugins/example-plugin.ts b/packages/plugins/example-plugin.ts index e32fe51..ba99801 100644 --- a/packages/plugins/example-plugin.ts +++ b/packages/plugins/example-plugin.ts @@ -16,7 +16,7 @@ export interface AnalysisResult { export interface Violation { ruleId: string; ruleName: string; - severity: 'info' | 'warning' | 'error' | 'critical'; + severity: "info" | "warning" | "error" | "critical"; location: { file?: string; line?: number; @@ -47,18 +47,18 @@ export interface Rule { * Example: Gas Optimization Rule */ export class UnoptimizedLoopRule implements Rule { - readonly id = 'unoptimized-loop'; - readonly name = 'Unoptimized Loop'; - readonly description = 'Detects loops that read array length in each iteration'; - readonly languages = ['solidity', 'rust']; + readonly id = "unoptimized-loop"; + readonly name = "Unoptimized Loop"; + readonly description = + "Detects loops that read array length in each iteration"; + readonly languages = ["solidity", "rust"]; analyze(code: string): Violation[] { const violations: Violation[] = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Pattern: for (... < array.length ...) without caching - const loopPattern = - /for\s*\([^)]*<\s*(\w+)\.length[^)]*\)/g; + const loopPattern = /for\s*\([^)]*<\s*(\w+)\.length[^)]*\)/g; lines.forEach((line, lineNum) => { let match; @@ -66,7 +66,7 @@ export class UnoptimizedLoopRule implements Rule { violations.push({ ruleId: this.id, ruleName: this.name, - severity: 'high', + severity: "high", location: { line: lineNum + 1, column: match.index, @@ -85,25 +85,25 @@ export class UnoptimizedLoopRule implements Rule { * Example: Security Rule */ export class MissingReentrancyGuardRule implements Rule { - readonly id = 'missing-reentrancy-guard'; - readonly name = 'Missing Reentrancy Guard'; - readonly description = - 'Detects external calls without reentrancy protection'; - readonly languages = ['solidity']; + readonly id = "missing-reentrancy-guard"; + readonly name = "Missing Reentrancy Guard"; + readonly description = "Detects external calls without reentrancy protection"; + readonly languages = ["solidity"]; analyze(code: string): Violation[] { const violations: Violation[] = []; - const hasGuard = code.includes('nonReentrant'); + const hasGuard = code.includes("nonReentrant"); const hasExternalCall = /\.call|\.delegatecall|\.transfer/.test(code); if (hasExternalCall && !hasGuard) { violations.push({ ruleId: this.id, ruleName: this.name, - severity: 'critical', + severity: "critical", location: { line: 1 }, - message: 'External calls detected without reentrancy guard', - suggestion: 'Add @nonReentrant modifier to functions making external calls', + message: "External calls detected without reentrancy guard", + suggestion: + "Add @nonReentrant modifier to functions making external calls", }); } @@ -115,18 +115,17 @@ export class MissingReentrancyGuardRule implements Rule { * Example: Code Quality Rule */ export class NamingConventionRule implements Rule { - readonly id = 'naming-convention'; - readonly name = 'Naming Convention'; - readonly description = 'Checks adherence to naming conventions'; - readonly languages = ['solidity', 'rust']; + readonly id = "naming-convention"; + readonly name = "Naming Convention"; + readonly description = "Checks adherence to naming conventions"; + readonly languages = ["solidity", "rust"]; analyze(code: string): Violation[] { const violations: Violation[] = []; - const lines = code.split('\n'); + const lines = code.split("\n"); // Check for camelCase violations - const nonStandardNames = - /(?:function|let|const|var)\s+([a-z]+_[a-z]+)/g; + const nonStandardNames = /(?:function|let|const|var)\s+([a-z]+_[a-z]+)/g; lines.forEach((line, lineNum) => { let match; @@ -134,15 +133,15 @@ export class NamingConventionRule implements Rule { violations.push({ ruleId: this.id, ruleName: this.name, - severity: 'warning', + severity: "warning", location: { line: lineNum + 1, column: match.index, }, message: `Non-standard naming: '${match[1]}' should use camelCase`, - suggestion: `Rename to: '${ - match[1].replace(/_(.)/g, (g) => g[1].toUpperCase()) - }'`, + suggestion: `Rename to: '${match[1].replace(/_(.)/g, (g) => + g[1].toUpperCase(), + )}'`, }); } }); @@ -232,7 +231,7 @@ export function createRegistry(): PluginRegistry { export interface PluginConfig { enabledRules?: string[]; disabledRules?: string[]; - ruleSeverityThreshold?: 'info' | 'warning' | 'error' | 'critical'; + ruleSeverityThreshold?: "info" | "warning" | "error" | "critical"; customOptions?: Record; } @@ -245,7 +244,7 @@ export class ConfigurableRegistry extends PluginRegistry { } analyzeCode(code: string, language: string): AnalysisResult { - let result = super.analyzeCode(code, language); + const result = super.analyzeCode(code, language); // Filter violations based on configuration if (this.config.enabledRules) { @@ -261,7 +260,7 @@ export class ConfigurableRegistry extends PluginRegistry { } if (this.config.ruleSeverityThreshold) { - const severityLevels = ['info', 'warning', 'error', 'critical']; + const severityLevels = ["info", "warning", "error", "critical"]; const thresholdIndex = severityLevels.indexOf( this.config.ruleSeverityThreshold, ); @@ -300,7 +299,7 @@ export async function exampleUsage(): Promise { `; // Analyze code - const result = registry.analyzeCode(solidityCode, 'solidity'); + const result = registry.analyzeCode(solidityCode, "solidity"); console.log(`Found ${result.violations.length} violations`); for (const violation of result.violations) { @@ -311,15 +310,17 @@ export async function exampleUsage(): Promise { // Example with configuration const configRegistry = new ConfigurableRegistry({ - enabledRules: ['unoptimized-loop', 'missing-reentrancy-guard'], - ruleSeverityThreshold: 'warning', + enabledRules: ["unoptimized-loop", "missing-reentrancy-guard"], + ruleSeverityThreshold: "warning", }); - const filteredResult = configRegistry.analyzeCode(solidityCode, 'solidity'); - console.log(`\nFiltered result: ${filteredResult.violations.length} violations`); + const filteredResult = configRegistry.analyzeCode(solidityCode, "solidity"); + console.log( + `\nFiltered result: ${filteredResult.violations.length} violations`, + ); } // Run if executed directly -if (typeof module !== 'undefined' && require.main === module) { +if (typeof module !== "undefined" && require.main === module) { exampleUsage().catch(console.error); } diff --git a/packages/plugins/manifest-validator.ts b/packages/plugins/manifest-validator.ts index 24b85ad..aa509dd 100644 --- a/packages/plugins/manifest-validator.ts +++ b/packages/plugins/manifest-validator.ts @@ -3,14 +3,17 @@ * Validates plugin formats and compatibility */ -import { PluginManifest, PluginRuleDefinition } from './plugin-manifest'; -import { CompatibilityCheckResult, CompatibilityChecker } from './version-compat'; -import { optimizePluginRules } from './rule-set-optimizer'; +import { PluginManifest, PluginRuleDefinition } from "./plugin-manifest"; +import { + CompatibilityCheckResult, + CompatibilityChecker, +} from "./version-compat"; +import { optimizePluginRules } from "./rule-set-optimizer"; export interface ValidationError { field: string; error: string; - severity: 'error' | 'warning'; + severity: "error" | "warning"; } export interface ManifestValidationResult { @@ -35,14 +38,14 @@ export class ManifestValidator { // Check required fields const requiredFields: (keyof PluginManifest)[] = [ - 'id', - 'name', - 'version', - 'description', - 'languages', - 'capabilities', - 'main', - 'gasguardVersion', + "id", + "name", + "version", + "description", + "languages", + "capabilities", + "main", + "gasguardVersion", ]; for (const field of requiredFields) { @@ -50,7 +53,7 @@ export class ManifestValidator { result.errors.push({ field: field as string, error: `Required field '${field}' is missing`, - severity: 'error', + severity: "error", }); result.valid = false; } @@ -72,10 +75,10 @@ export class ManifestValidator { this.validateVersionRange(manifest, result); } if (manifest.author) { - this.validateContact(manifest.author, 'author', result); + this.validateContact(manifest.author, "author", result); } if (manifest.support) { - this.validateContact(manifest.support, 'support', result); + this.validateContact(manifest.support, "support", result); } if (manifest.repository) { this.validateRepository(manifest, result); @@ -99,17 +102,20 @@ export class ManifestValidator { return result; } - private static validateId(manifest: any, result: ManifestValidationResult): void { + private static validateId( + manifest: any, + result: ManifestValidationResult, + ): void { const id = manifest.id; // Check format: lowercase, hyphens, alphanumeric if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id)) { result.errors.push({ - field: 'id', + field: "id", error: - 'Plugin ID must be lowercase alphanumeric with hyphens ' + + "Plugin ID must be lowercase alphanumeric with hyphens " + '(e.g., "my-plugin")', - severity: 'error', + severity: "error", }); result.valid = false; } @@ -117,55 +123,61 @@ export class ManifestValidator { // Check length if (id.length < 3 || id.length > 50) { result.errors.push({ - field: 'id', - error: 'Plugin ID must be between 3 and 50 characters', - severity: 'error', + field: "id", + error: "Plugin ID must be between 3 and 50 characters", + severity: "error", }); result.valid = false; } } - private static validateVersion(manifest: any, result: ManifestValidationResult): void { + private static validateVersion( + manifest: any, + result: ManifestValidationResult, + ): void { const version = manifest.version; // Check semantic version format if (!/^\d+\.\d+\.\d+/.test(version)) { result.errors.push({ - field: 'version', + field: "version", error: 'Version must follow semantic versioning (e.g., "1.0.0")', - severity: 'error', + severity: "error", }); result.valid = false; } } - private static validateLanguages(manifest: any, result: ManifestValidationResult): void { + private static validateLanguages( + manifest: any, + result: ManifestValidationResult, + ): void { const languages = manifest.languages; if (!Array.isArray(languages) || languages.length === 0) { result.errors.push({ - field: 'languages', - error: 'At least one language must be specified', - severity: 'error', + field: "languages", + error: "At least one language must be specified", + severity: "error", }); result.valid = false; } const validLanguages = [ - 'solidity', - 'rust', - 'vyper', - 'python', - 'javascript', - 'typescript', + "solidity", + "rust", + "vyper", + "python", + "javascript", + "typescript", ]; for (const lang of languages) { if (!validLanguages.includes(lang)) { result.warnings.push({ - field: 'languages', + field: "languages", error: `Unknown language: ${lang}`, - severity: 'warning', + severity: "warning", }); } } @@ -179,28 +191,28 @@ export class ManifestValidator { if (!Array.isArray(capabilities) || capabilities.length === 0) { result.errors.push({ - field: 'capabilities', - error: 'At least one capability must be specified', - severity: 'error', + field: "capabilities", + error: "At least one capability must be specified", + severity: "error", }); result.valid = false; } const validCapabilities = [ - 'gas-optimization', - 'security-analysis', - 'code-quality', - 'performance', - 'compatibility', - 'custom', + "gas-optimization", + "security-analysis", + "code-quality", + "performance", + "compatibility", + "custom", ]; for (const cap of capabilities) { if (!validCapabilities.includes(cap)) { result.warnings.push({ - field: 'capabilities', + field: "capabilities", error: `Unknown capability: ${cap}. Use 'custom' for custom capabilities`, - severity: 'warning', + severity: "warning", }); } } @@ -214,9 +226,9 @@ export class ManifestValidator { if (!gasguardVersion.min) { result.errors.push({ - field: 'gasguardVersion.min', - error: 'Minimum version is required', - severity: 'error', + field: "gasguardVersion.min", + error: "Minimum version is required", + severity: "error", }); result.valid = false; } @@ -228,9 +240,9 @@ export class ManifestValidator { // Simple check: min should be <= max (basic comparison) if (min.localeCompare(max) > 0) { result.errors.push({ - field: 'gasguardVersion', - error: 'Minimum version should not be greater than maximum version', - severity: 'error', + field: "gasguardVersion", + error: "Minimum version should not be greater than maximum version", + severity: "error", }); result.valid = false; } @@ -245,36 +257,39 @@ export class ManifestValidator { if (contact.email && !this.isValidEmail(contact.email)) { result.warnings.push({ field: `${fieldName}.email`, - error: 'Invalid email format', - severity: 'warning', + error: "Invalid email format", + severity: "warning", }); } if (contact.url && !this.isValidUrl(contact.url)) { result.warnings.push({ field: `${fieldName}.url`, - error: 'Invalid URL format', - severity: 'warning', + error: "Invalid URL format", + severity: "warning", }); } } - private static validateRepository(manifest: any, result: ManifestValidationResult): void { + private static validateRepository( + manifest: any, + result: ManifestValidationResult, + ): void { const repo = manifest.repository; - if (repo.type && !['git', 'svn', 'hg', 'pijul'].includes(repo.type)) { + if (repo.type && !["git", "svn", "hg", "pijul"].includes(repo.type)) { result.warnings.push({ - field: 'repository.type', + field: "repository.type", error: `Unknown repository type: ${repo.type}`, - severity: 'warning', + severity: "warning", }); } if (repo.url && !this.isValidUrl(repo.url)) { result.errors.push({ - field: 'repository.url', - error: 'Invalid repository URL', - severity: 'error', + field: "repository.url", + error: "Invalid repository URL", + severity: "error", }); } } @@ -289,8 +304,8 @@ export class ManifestValidator { if (!dep?.versionRange?.min) { result.errors.push({ field: `dependencies.${depId}`, - error: 'Dependency must specify versionRange.min', - severity: 'error', + error: "Dependency must specify versionRange.min", + severity: "error", }); result.valid = false; } @@ -307,8 +322,8 @@ export class ManifestValidator { if (!range?.min) { result.warnings.push({ field: `peerDependencies.${peerId}`, - error: 'Peer dependency should specify min version', - severity: 'warning', + error: "Peer dependency should specify min version", + severity: "warning", }); } } @@ -320,11 +335,11 @@ export class ManifestValidator { ): void { const schema = manifest.configSchema; - if (schema && typeof schema !== 'object') { + if (schema && typeof schema !== "object") { result.errors.push({ - field: 'configSchema', - error: 'Config schema must be a valid JSON Schema object', - severity: 'error', + field: "configSchema", + error: "Config schema must be a valid JSON Schema object", + severity: "error", }); result.valid = false; } @@ -333,42 +348,57 @@ export class ManifestValidator { // Basic check: defaultConfig keys should be in configSchema const schemaKeys = Object.keys(manifest.configSchema.properties || {}); for (const key of Object.keys(manifest.defaultConfig)) { - if (!schemaKeys.includes(key) && !manifest.configSchema.additionalProperties) { + if ( + !schemaKeys.includes(key) && + !manifest.configSchema.additionalProperties + ) { result.warnings.push({ - field: 'defaultConfig', + field: "defaultConfig", error: `Default config key '${key}' not found in configSchema`, - severity: 'warning', + severity: "warning", }); } } } } - private static validateLicense(manifest: any, result: ManifestValidationResult): void { + private static validateLicense( + manifest: any, + result: ManifestValidationResult, + ): void { const license = manifest.license; // Common SPDX license identifiers - const commonLicenses = ['MIT', 'Apache-2.0', 'GPL-3.0', 'BSD-3-Clause', 'ISC']; + const commonLicenses = [ + "MIT", + "Apache-2.0", + "GPL-3.0", + "BSD-3-Clause", + "ISC", + ]; if (license && !commonLicenses.includes(license)) { result.warnings.push({ - field: 'license', + field: "license", error: `Unknown license: ${license}. ` + `Use SPDX license identifiers (https://spdx.org/licenses/)`, - severity: 'warning', + severity: "warning", }); } } - private static validateRuleSet(manifest: any, result: ManifestValidationResult): void { + private static validateRuleSet( + manifest: any, + result: ManifestValidationResult, + ): void { const rules = manifest.rules; if (!Array.isArray(rules)) { result.errors.push({ - field: 'rules', - error: 'rules must be an array when provided', - severity: 'error', + field: "rules", + error: "rules must be an array when provided", + severity: "error", }); result.valid = false; return; @@ -379,8 +409,8 @@ export class ManifestValidator { if (!rule.id || !rule.version || !rule.name || !rule.description) { result.errors.push({ field: `rules[${i}]`, - error: 'Each rule requires id, version, name, and description', - severity: 'error', + error: "Each rule requires id, version, name, and description", + severity: "error", }); result.valid = false; } @@ -388,8 +418,8 @@ export class ManifestValidator { if (rule.version && !/^\d+\.\d+\.\d+/.test(rule.version)) { result.errors.push({ field: `rules[${i}].version`, - error: 'Rule version must follow semantic versioning', - severity: 'error', + error: "Rule version must follow semantic versioning", + severity: "error", }); result.valid = false; } @@ -398,14 +428,14 @@ export class ManifestValidator { const optimized = optimizePluginRules(rules as PluginRuleDefinition[]); for (const removed of optimized.removedRules) { const msg = - removed.reason === 'duplicate-id' + removed.reason === "duplicate-id" ? `Duplicate rule '${removed.removedRuleId}' overlaps with '${removed.keptRuleId}'` : `Overlapping rule intent detected for '${removed.removedRuleId}' (merged into '${removed.keptRuleId}')`; result.warnings.push({ - field: 'rules', + field: "rules", error: msg, - severity: 'warning', + severity: "warning", }); } } @@ -502,7 +532,10 @@ export class CompatibilityValidator { } else { results.set( peerId, - CompatibilityChecker.checkDependencyCompatibility(peerVersion, range), + CompatibilityChecker.checkDependencyCompatibility( + peerVersion, + range, + ), ); } } diff --git a/packages/plugins/plugin-manifest.ts b/packages/plugins/plugin-manifest.ts index eee8d0a..f948c20 100644 --- a/packages/plugins/plugin-manifest.ts +++ b/packages/plugins/plugin-manifest.ts @@ -12,34 +12,34 @@ export type SemanticVersion = string; * Plugin severity level */ export enum PluginSeverity { - INFO = 'info', - WARNING = 'warning', - ERROR = 'error', - CRITICAL = 'critical', + INFO = "info", + WARNING = "warning", + ERROR = "error", + CRITICAL = "critical", } /** * Supported programming languages for analysis */ export enum SupportedLanguage { - SOLIDITY = 'solidity', - RUST = 'rust', - VYPER = 'vyper', - PYTHON = 'python', - JAVASCRIPT = 'javascript', - TYPESCRIPT = 'typescript', + SOLIDITY = "solidity", + RUST = "rust", + VYPER = "vyper", + PYTHON = "python", + JAVASCRIPT = "javascript", + TYPESCRIPT = "typescript", } /** * Plugin capability category */ export enum PluginCapability { - GAS_OPTIMIZATION = 'gas-optimization', - SECURITY_ANALYSIS = 'security-analysis', - CODE_QUALITY = 'code-quality', - PERFORMANCE = 'performance', - COMPATIBILITY = 'compatibility', - CUSTOM = 'custom', + GAS_OPTIMIZATION = "gas-optimization", + SECURITY_ANALYSIS = "security-analysis", + CODE_QUALITY = "code-quality", + PERFORMANCE = "performance", + COMPATIBILITY = "compatibility", + CUSTOM = "custom", } /** @@ -79,7 +79,7 @@ export interface ContactInfo { * Plugin repository information */ export interface RepositoryInfo { - type: 'git' | 'svn' | 'hg' | 'pijul'; + type: "git" | "svn" | "hg" | "pijul"; url: string; directory?: string; } @@ -196,7 +196,7 @@ export interface PluginManifest { funding?: FundingInfo[]; /** Plugin status */ - status?: 'stable' | 'beta' | 'alpha' | 'deprecated'; + status?: "stable" | "beta" | "alpha" | "deprecated"; /** Deprecation message if deprecated */ deprecationMessage?: string; @@ -252,7 +252,7 @@ export interface PluginRegistryEntry { * Compatibility check result */ export interface CompatibilityStatus { - type: 'error' | 'warning' | 'info'; + type: "error" | "warning" | "info"; code: string; message: string; details?: any; diff --git a/packages/plugins/rule-set-optimizer.ts b/packages/plugins/rule-set-optimizer.ts index 816bb07..bf06f81 100644 --- a/packages/plugins/rule-set-optimizer.ts +++ b/packages/plugins/rule-set-optimizer.ts @@ -3,12 +3,12 @@ * Used to detect duplicate and overlapping rule definitions. */ -import { PluginRuleDefinition } from './plugin-manifest'; +import { PluginRuleDefinition } from "./plugin-manifest"; export interface PluginRuleDuplicate { keptRuleId: string; removedRuleId: string; - reason: 'duplicate-id' | 'overlapping-intent'; + reason: "duplicate-id" | "overlapping-intent"; } export interface PluginRuleOptimizationResult { @@ -19,7 +19,7 @@ export interface PluginRuleOptimizationResult { function normalize(text: string): string[] { return text .toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') + .replace(/[^a-z0-9\s]/g, " ") .split(/\s+/) .filter((t) => t.length > 2); } @@ -37,7 +37,10 @@ function intentSignature(rule: PluginRuleDefinition): Set { return new Set(normalize(`${rule.id} ${rule.name} ${rule.description}`)); } -function chooseCanonical(a: PluginRuleDefinition, b: PluginRuleDefinition): PluginRuleDefinition { +function chooseCanonical( + a: PluginRuleDefinition, + b: PluginRuleDefinition, +): PluginRuleDefinition { if (a.description.length !== b.description.length) { return a.description.length > b.description.length ? a : b; } @@ -63,7 +66,7 @@ export function optimizePluginRules( removedRules.push({ keptRuleId: keep.id, removedRuleId: drop.id, - reason: 'duplicate-id', + reason: "duplicate-id", }); } @@ -82,7 +85,7 @@ export function optimizePluginRules( removedRules.push({ keptRuleId: keep.id, removedRuleId: drop.id, - reason: 'overlapping-intent', + reason: "overlapping-intent", }); merged = true; break; diff --git a/packages/plugins/rule-template.ts b/packages/plugins/rule-template.ts index af129fd..5131e06 100644 --- a/packages/plugins/rule-template.ts +++ b/packages/plugins/rule-template.ts @@ -2,8 +2,8 @@ export interface RuleMetadata { id: string; name: string; description: string; - severity: 'low' | 'medium' | 'high' | 'critical'; - category: 'gas' | 'security' | 'best-practice'; + severity: "low" | "medium" | "high" | "critical"; + category: "gas" | "security" | "best-practice"; } export interface RuleMatch { @@ -18,33 +18,38 @@ export abstract class BaseRule { abstract analyze(code: string): RuleMatch[]; - protected createMatch(line: number, column: number, message: string, suggestion?: string): RuleMatch { + protected createMatch( + line: number, + column: number, + message: string, + suggestion?: string, + ): RuleMatch { return { line, column, message, suggestion }; } } export class ExampleStorageRule extends BaseRule { metadata: RuleMetadata = { - id: 'inefficient-storage', - name: 'Inefficient Storage Pattern', - description: 'Detects redundant storage operations that increase gas costs', - severity: 'medium', - category: 'gas', + id: "inefficient-storage", + name: "Inefficient Storage Pattern", + description: "Detects redundant storage operations that increase gas costs", + severity: "medium", + category: "gas", }; analyze(code: string): RuleMatch[] { const matches: RuleMatch[] = []; - const lines = code.split('\n'); + const lines = code.split("\n"); lines.forEach((line, index) => { - if (line.includes('storage.set') && line.includes('storage.get')) { + if (line.includes("storage.set") && line.includes("storage.get")) { matches.push( this.createMatch( index + 1, 0, - 'Redundant storage operation detected', - 'Cache the value in memory instead of multiple storage reads' - ) + "Redundant storage operation detected", + "Cache the value in memory instead of multiple storage reads", + ), ); } }); diff --git a/packages/plugins/security/hybrid-rules.ts b/packages/plugins/security/hybrid-rules.ts index e3e45cf..348b564 100644 --- a/packages/plugins/security/hybrid-rules.ts +++ b/packages/plugins/security/hybrid-rules.ts @@ -1,4 +1,4 @@ -import { Finding, Rule, Severity } from '@engine/core'; +import { Finding, Rule, Severity } from "@engine/core"; export interface HybridIssue extends Finding { gasImpact: number; @@ -10,7 +10,7 @@ export interface HybridRuleResult { ruleName: string; securityIssues: HybridIssue[]; gasIssues: GasIssue[]; - riskLevel: 'low' | 'medium' | 'high' | 'critical'; + riskLevel: "low" | "medium" | "high" | "critical"; } export class HybridRuleEngine { @@ -26,13 +26,13 @@ export class HybridRuleEngine { new IntegerOverflowRule(), new ReentrancyGasRule(), new DoSVulnerabilityRule(), - new AccessControlGasRule() + new AccessControlGasRule(), ]; } async analyzeCode(ast: any, sourceCode: string): Promise { const results: HybridRuleResult[] = []; - + for (const rule of this.rules) { const result = await rule.check(ast, sourceCode); if (result.securityIssues.length > 0 || result.gasIssues.length > 0) { @@ -47,16 +47,16 @@ export class HybridRuleEngine { let score = 0; for (const issue of issues) { switch (issue.severity) { - case 'critical': + case "critical": score += 10; break; - case 'high': + case "high": score += 7; break; - case 'medium': + case "medium": score += 4; break; - case 'low': + case "low": score += 1; break; } @@ -75,61 +75,66 @@ abstract class HybridRule { protected createHybridIssue( title: string, description: string, - severity: 'low' | 'medium' | 'high' | 'critical', + severity: "low" | "medium" | "high" | "critical", line: number, gasImpact: number, - gasOptimization?: string + gasOptimization?: string, ): HybridIssue { return { ruleId: this.name, message: description, severity: severity as Severity, location: { - file: '', + file: "", startLine: line, - endLine: line + endLine: line, }, estimatedGasSavings: gasImpact, suggestedFix: { - description: gasOptimization || 'Review and fix this security vulnerability' + description: + gasOptimization || "Review and fix this security vulnerability", }, gasImpact, gasOptimization, - securityCategory: 'security' + securityCategory: "security", }; } } class UncheckedExternalCallRule extends HybridRule { - name = 'Unchecked External Call'; - description = 'Detects unchecked external calls that may lead to failed transactions and wasted gas'; + name = "Unchecked External Call"; + description = + "Detects unchecked external calls that may lead to failed transactions and wasted gas"; async check(ast: any, sourceCode: string): Promise { const issues: HybridIssue[] = []; const gasIssues: GasIssue[] = []; // Pattern matching for unchecked calls - const uncheckedCallPattern = /\.(call|delegatecall|staticcall|transfer|send)\([^)]*\)/g; + const uncheckedCallPattern = + /\.(call|delegatecall|staticcall|transfer|send)\([^)]*\)/g; let match; - + while ((match = uncheckedCallPattern.exec(sourceCode)) !== null) { - const line = sourceCode.substring(0, match.index).split('\n').length; - - issues.push(this.createHybridIssue( - 'Unchecked External Call', - 'External call return value is not checked, which can lead to silent failures and wasted gas', - 'high', - line, - 21000, // Base gas cost for failed call - 'Add require() or check return value to handle failures gracefully' - )); + const line = sourceCode.substring(0, match.index).split("\n").length; + + issues.push( + this.createHybridIssue( + "Unchecked External Call", + "External call return value is not checked, which can lead to silent failures and wasted gas", + "high", + line, + 21000, // Base gas cost for failed call + "Add require() or check return value to handle failures gracefully", + ), + ); gasIssues.push({ - title: 'Potential Gas Waste from Unchecked Call', - description: 'Failed external calls still consume gas', + title: "Potential Gas Waste from Unchecked Call", + description: "Failed external calls still consume gas", line, gasAmount: 21000, - optimization: 'Check return values before proceeding' + optimization: "Check return values before proceeding", }); } @@ -137,42 +142,50 @@ class UncheckedExternalCallRule extends HybridRule { ruleName: this.name, securityIssues: issues, gasIssues, - riskLevel: issues.length > 0 ? 'high' : 'low' + riskLevel: issues.length > 0 ? "high" : "low", }; } } class IntegerOverflowRule extends HybridRule { - name = 'Integer Overflow/Underflow'; - description = 'Detects potential integer overflow/underflow that can cause security issues and unexpected gas consumption'; + name = "Integer Overflow/Underflow"; + description = + "Detects potential integer overflow/underflow that can cause security issues and unexpected gas consumption"; async check(ast: any, sourceCode: string): Promise { const issues: HybridIssue[] = []; const gasIssues: GasIssue[] = []; // Look for arithmetic operations without safe math - const unsafeMathPattern = /(?:\w+\s*[\+\-\*\/]\s*\w+|\w+\s*\+=\s*\w+|\w+\s*-=\s*\w+)/g; + const unsafeMathPattern = + /(?:\w+\s*[\+\-\*\/]\s*\w+|\w+\s*\+=\s*\w+|\w+\s*-=\s*\w+)/g; let match; - + while ((match = unsafeMathPattern.exec(sourceCode)) !== null) { - const line = sourceCode.substring(0, match.index).split('\n').length; - - if (!sourceCode.includes('SafeMath') && !sourceCode.includes('using SafeMath')) { - issues.push(this.createHybridIssue( - 'Potential Integer Overflow', - 'Arithmetic operation without overflow protection can lead to unexpected behavior', - 'medium', - line, - 5000, // Potential gas from unexpected behavior - 'Use SafeMath library or Solidity 0.8+ built-in overflow protection' - )); + const line = sourceCode.substring(0, match.index).split("\n").length; + + if ( + !sourceCode.includes("SafeMath") && + !sourceCode.includes("using SafeMath") + ) { + issues.push( + this.createHybridIssue( + "Potential Integer Overflow", + "Arithmetic operation without overflow protection can lead to unexpected behavior", + "medium", + line, + 5000, // Potential gas from unexpected behavior + "Use SafeMath library or Solidity 0.8+ built-in overflow protection", + ), + ); gasIssues.push({ - title: 'Gas Inefficiency from Unsafe Math', - description: 'Overflow conditions can cause transaction reversals and gas waste', + title: "Gas Inefficiency from Unsafe Math", + description: + "Overflow conditions can cause transaction reversals and gas waste", line, gasAmount: 5000, - optimization: 'Implement overflow checks or use SafeMath' + optimization: "Implement overflow checks or use SafeMath", }); } } @@ -181,41 +194,45 @@ class IntegerOverflowRule extends HybridRule { ruleName: this.name, securityIssues: issues, gasIssues, - riskLevel: issues.length > 0 ? 'medium' : 'low' + riskLevel: issues.length > 0 ? "medium" : "low", }; } } class ReentrancyGasRule extends HybridRule { - name = 'Reentrancy Gas Risk'; - description = 'Detects reentrancy vulnerabilities that can lead to gas drain attacks'; + name = "Reentrancy Gas Risk"; + description = + "Detects reentrancy vulnerabilities that can lead to gas drain attacks"; async check(ast: any, sourceCode: string): Promise { const issues: HybridIssue[] = []; const gasIssues: GasIssue[] = []; // Look for external calls before state changes - const callBeforeStateChangePattern = /(\w+\.(call|transfer|send)\([^)]*\)[\s\S]*?)(\w+\s*=)/g; + const callBeforeStateChangePattern = + /(\w+\.(call|transfer|send)\([^)]*\)[\s\S]*?)(\w+\s*=)/g; let match; - + while ((match = callBeforeStateChangePattern.exec(sourceCode)) !== null) { - const line = sourceCode.substring(0, match.index).split('\n').length; - - issues.push(this.createHybridIssue( - 'Reentrancy Vulnerability', - 'External call before state change creates reentrancy risk', - 'critical', - line, - 50000, // Potential gas drain from reentrancy attack - 'Implement checks-effects-interactions pattern' - )); + const line = sourceCode.substring(0, match.index).split("\n").length; + + issues.push( + this.createHybridIssue( + "Reentrancy Vulnerability", + "External call before state change creates reentrancy risk", + "critical", + line, + 50000, // Potential gas drain from reentrancy attack + "Implement checks-effects-interactions pattern", + ), + ); gasIssues.push({ - title: 'Gas Drain Risk from Reentrancy', - description: 'Reentrancy attacks can drain gas through recursive calls', + title: "Gas Drain Risk from Reentrancy", + description: "Reentrancy attacks can drain gas through recursive calls", line, gasAmount: 50000, - optimization: 'Use reentrancy guards and proper state update order' + optimization: "Use reentrancy guards and proper state update order", }); } @@ -223,14 +240,14 @@ class ReentrancyGasRule extends HybridRule { ruleName: this.name, securityIssues: issues, gasIssues, - riskLevel: issues.length > 0 ? 'critical' : 'low' + riskLevel: issues.length > 0 ? "critical" : "low", }; } } class DoSVulnerabilityRule extends HybridRule { - name = 'Denial of Service Gas'; - description = 'Detects patterns that can lead to gas-based DoS attacks'; + name = "Denial of Service Gas"; + description = "Detects patterns that can lead to gas-based DoS attacks"; async check(ast: any, sourceCode: string): Promise { const issues: HybridIssue[] = []; @@ -239,25 +256,27 @@ class DoSVulnerabilityRule extends HybridRule { // Look for unbounded loops or operations const unboundedLoopPattern = /for\s*\(\s*.*\s*in\s*.*\s*\)/g; let match; - + while ((match = unboundedLoopPattern.exec(sourceCode)) !== null) { - const line = sourceCode.substring(0, match.index).split('\n').length; - - issues.push(this.createHybridIssue( - 'Potential DoS via Unbounded Loop', - 'Unbounded loops can cause gas exhaustion attacks', - 'high', - line, - 100000, // Potential gas consumption - 'Add bounds checking or limit iteration count' - )); + const line = sourceCode.substring(0, match.index).split("\n").length; + + issues.push( + this.createHybridIssue( + "Potential DoS via Unbounded Loop", + "Unbounded loops can cause gas exhaustion attacks", + "high", + line, + 100000, // Potential gas consumption + "Add bounds checking or limit iteration count", + ), + ); gasIssues.push({ - title: 'High Gas Consumption Risk', - description: 'Unbounded operations can lead to gas exhaustion', + title: "High Gas Consumption Risk", + description: "Unbounded operations can lead to gas exhaustion", line, gasAmount: 100000, - optimization: 'Implement proper bounds and limits' + optimization: "Implement proper bounds and limits", }); } @@ -265,14 +284,14 @@ class DoSVulnerabilityRule extends HybridRule { ruleName: this.name, securityIssues: issues, gasIssues, - riskLevel: issues.length > 0 ? 'high' : 'low' + riskLevel: issues.length > 0 ? "high" : "low", }; } } class AccessControlGasRule extends HybridRule { - name = 'Access Control Gas Efficiency'; - description = 'Detects access control issues and their gas implications'; + name = "Access Control Gas Efficiency"; + description = "Detects access control issues and their gas implications"; async check(ast: any, sourceCode: string): Promise { const issues: HybridIssue[] = []; @@ -281,25 +300,27 @@ class AccessControlGasRule extends HybridRule { // Look for inefficient access control patterns const inefficientAccessPattern = /require\s*\(\s*msg\.sender\s*==\s*owner/g; let match; - + while ((match = inefficientAccessPattern.exec(sourceCode)) !== null) { - const line = sourceCode.substring(0, match.index).split('\n').length; - - issues.push(this.createHybridIssue( - 'Inefficient Access Control', - 'Direct owner comparison is less gas-efficient and potentially insecure', - 'medium', - line, - 2000, // Extra gas cost - 'Use modifiers or role-based access control for better gas efficiency' - )); + const line = sourceCode.substring(0, match.index).split("\n").length; + + issues.push( + this.createHybridIssue( + "Inefficient Access Control", + "Direct owner comparison is less gas-efficient and potentially insecure", + "medium", + line, + 2000, // Extra gas cost + "Use modifiers or role-based access control for better gas efficiency", + ), + ); gasIssues.push({ - title: 'Inefficient Access Control Check', - description: 'Direct comparison costs more gas than optimized patterns', + title: "Inefficient Access Control Check", + description: "Direct comparison costs more gas than optimized patterns", line, gasAmount: 2000, - optimization: 'Use modifiers or cached owner address' + optimization: "Use modifiers or cached owner address", }); } @@ -307,7 +328,7 @@ class AccessControlGasRule extends HybridRule { ruleName: this.name, securityIssues: issues, gasIssues, - riskLevel: issues.length > 0 ? 'medium' : 'low' + riskLevel: issues.length > 0 ? "medium" : "low", }; } } diff --git a/packages/plugins/version-compat.ts b/packages/plugins/version-compat.ts index b22c8d4..6dee03c 100644 --- a/packages/plugins/version-compat.ts +++ b/packages/plugins/version-compat.ts @@ -3,7 +3,7 @@ * Handles semantic versioning and compatibility checking */ -import { SemanticVersion, VersionRange } from './plugin-manifest'; +import { SemanticVersion, VersionRange } from "./plugin-manifest"; /** * Represents a semantic version @@ -37,7 +37,7 @@ export class Version { throw new Error(`Invalid semantic version: ${versionString}`); } - const version = new Version('0.0.0'); + const version = new Version("0.0.0"); version.major = parseInt(match[1], 10); version.minor = parseInt(match[2], 10); version.patch = parseInt(match[3], 10); @@ -127,7 +127,7 @@ export class Version { * Increment patch version (0.0.1 -> 0.0.2) */ nextPatch(): Version { - const v = new Version('0.0.0'); + const v = new Version("0.0.0"); v.major = this.major; v.minor = this.minor; v.patch = this.patch + 1; @@ -138,7 +138,7 @@ export class Version { * Increment minor version (0.1.0 -> 0.2.0) */ nextMinor(): Version { - const v = new Version('0.0.0'); + const v = new Version("0.0.0"); v.major = this.major; v.minor = this.minor + 1; v.patch = 0; @@ -149,7 +149,7 @@ export class Version { * Increment major version (1.0.0 -> 2.0.0) */ nextMajor(): Version { - const v = new Version('0.0.0'); + const v = new Version("0.0.0"); v.major = this.major + 1; v.minor = 0; v.patch = 0; @@ -280,7 +280,7 @@ export class CompatibilityChecker { if (!VersionMatcher.satisfies(coreVersion, requiredRange)) { result.errors.push( `GasGuard core version ${coreVersion} does not satisfy ` + - `required range ${requiredRange.min}..${requiredRange.max || '*'}`, + `required range ${requiredRange.min}..${requiredRange.max || "*"}`, ); } else { result.compatible = true; @@ -305,7 +305,7 @@ export class CompatibilityChecker { if (!result.compatible) { result.errors.push( `Dependency version ${depVersion} does not satisfy ` + - `required range ${requiredRange.min}..${requiredRange.max || '*'}`, + `required range ${requiredRange.min}..${requiredRange.max || "*"}`, ); } diff --git a/packages/rules/gasGuard/gasguard.engine.ts b/packages/rules/gasGuard/gasguard.engine.ts index 272c6b5..366765e 100644 --- a/packages/rules/gasGuard/gasguard.engine.ts +++ b/packages/rules/gasGuard/gasguard.engine.ts @@ -2,7 +2,7 @@ import { SorobanAnalyzer } from "./src/languages/soroban.analyzer"; import { SolidityAnalyzerWrapper } from "./src/languages/solidity.analyzer"; export type ScanInput = { - language: 'soroban' | 'solidity' | 'vyper'; + language: "soroban" | "solidity" | "vyper"; source: string; }; @@ -13,9 +13,9 @@ export type ScanResult = { export class GasGuardEngine { async scan(input: ScanInput): Promise { switch (input.language) { - case 'soroban': + case "soroban": return new SorobanAnalyzer().analyze(input.source); - case 'solidity': + case "solidity": return new SolidityAnalyzerWrapper().analyze(input.source); default: throw new Error(`Unsupported language: ${input.language}`); diff --git a/packages/rules/gasGuard/src/languages/solidity.analyzer.ts b/packages/rules/gasGuard/src/languages/solidity.analyzer.ts index 9625e0e..dc878c5 100644 --- a/packages/rules/gasGuard/src/languages/solidity.analyzer.ts +++ b/packages/rules/gasGuard/src/languages/solidity.analyzer.ts @@ -1,4 +1,4 @@ -import { SolidityAnalyzer } from '../../../../libs/engine/analyzers/solidity-analyzer'; +import { SolidityAnalyzer } from "../../../../libs/engine/analyzers/solidity-analyzer"; export class SolidityAnalyzerWrapper { private analyzer: SolidityAnalyzer; @@ -8,9 +8,9 @@ export class SolidityAnalyzerWrapper { } async analyze(source: string) { - const result = await this.analyzer.analyze(source, 'contract.sol'); + const result = await this.analyzer.analyze(source, "contract.sol"); - const issues = result.findings.map(finding => ({ + const issues = result.findings.map((finding) => ({ ruleId: finding.ruleId, severity: finding.severity, message: finding.message, @@ -20,4 +20,4 @@ export class SolidityAnalyzerWrapper { return { issues }; } -} \ No newline at end of file +} diff --git a/packages/rules/gasGuard/src/languages/soroban.analyzer.ts b/packages/rules/gasGuard/src/languages/soroban.analyzer.ts index c601ebf..7d7ec2e 100644 --- a/packages/rules/gasGuard/src/languages/soroban.analyzer.ts +++ b/packages/rules/gasGuard/src/languages/soroban.analyzer.ts @@ -2,30 +2,30 @@ export class SorobanAnalyzer { analyze(source: string) { const issues = []; - if (source.includes('storage().instance().get')) { + if (source.includes("storage().instance().get")) { issues.push({ - ruleId: 'SOROBAN_STORAGE_REDUNDANT_READ', - severity: 'medium', - message: 'Repeated storage reads detected', - suggestion: 'Cache storage value in a local variable', + ruleId: "SOROBAN_STORAGE_REDUNDANT_READ", + severity: "medium", + message: "Repeated storage reads detected", + suggestion: "Cache storage value in a local variable", }); } - if (source.includes('for') && source.includes('storage().instance().get')) { + if (source.includes("for") && source.includes("storage().instance().get")) { issues.push({ - ruleId: 'SOROBAN_LOOP_STORAGE_ACCESS', - severity: 'high', - message: 'Storage access inside loop detected', - suggestion: 'Batch reads or redesign storage layout', + ruleId: "SOROBAN_LOOP_STORAGE_ACCESS", + severity: "high", + message: "Storage access inside loop detected", + suggestion: "Batch reads or redesign storage layout", }); } - if (source.includes('.clone()')) { + if (source.includes(".clone()")) { issues.push({ - ruleId: 'SOROBAN_REDUNDANT_CLONE', - severity: 'low', - message: 'Unnecessary clone detected', - suggestion: 'Avoid cloning; return original or borrow', + ruleId: "SOROBAN_REDUNDANT_CLONE", + severity: "low", + message: "Unnecessary clone detected", + suggestion: "Avoid cloning; return original or borrow", }); } diff --git a/packages/rules/src/stellar/linting/mod.rs b/packages/rules/src/stellar/linting/mod.rs index af55a59..f69404f 100644 --- a/packages/rules/src/stellar/linting/mod.rs +++ b/packages/rules/src/stellar/linting/mod.rs @@ -6,10 +6,12 @@ pub mod soroban_rules; pub mod stellar_sdk_rules; pub mod gas_optimization_rules; +pub mod networking; pub use soroban_rules::*; pub use stellar_sdk_rules::*; pub use gas_optimization_rules::*; +pub use networking::*; use crate::{RuleViolation, ViolationSeverity}; @@ -31,6 +33,7 @@ impl SorobanLinter { rules.push(Box::new(stellar_sdk_rules::AddressValidationRule)); rules.push(Box::new(gas_optimization_rules::StorageReadRule)); rules.push(Box::new(gas_optimization_rules::EventEmissionRule)); + rules.push(Box::new(networking::NetworkValidationRule)); Self { rules } } diff --git a/packages/rules/src/stellar/linting/networking/mod.rs b/packages/rules/src/stellar/linting/networking/mod.rs new file mode 100644 index 0000000..11e5d63 --- /dev/null +++ b/packages/rules/src/stellar/linting/networking/mod.rs @@ -0,0 +1,9 @@ +//! Stellar Network Validation Rules +//! +//! Rules that detect missing network/environment validation in Soroban contracts. +//! Contracts may behave incorrectly across different Stellar networks (mainnet, testnet, futurenet) +//! if they don't validate the network passphrase or environment. + +pub mod network_validation; + +pub use network_validation::*; diff --git a/packages/rules/src/stellar/linting/networking/network_validation.rs b/packages/rules/src/stellar/linting/networking/network_validation.rs new file mode 100644 index 0000000..cab0fc9 --- /dev/null +++ b/packages/rules/src/stellar/linting/networking/network_validation.rs @@ -0,0 +1,294 @@ +//! Network Validation Rule +//! +//! Detects contracts that lack network/environment validation. +//! Soroban contracts should validate they're running on the expected network +//! to prevent issues when deployed across different Stellar networks. + +use crate::{RuleViolation, ViolationSeverity}; +use crate::stellar::linting::SorobanLintRule; + +/// Rule to detect missing network validation in Soroban contracts +pub struct NetworkValidationRule; + +impl SorobanLintRule for NetworkValidationRule { + fn id(&self) -> &'static str { + "stellar-network-validation" + } + + fn name(&self) -> &'static str { + "Stellar Network Validation" + } + + fn description(&self) -> &'static str { + "Detects contracts lacking network/environment validation. Contracts may behave incorrectly across networks." + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::High + } + + fn check(&self, source: &str, file_path: &str) -> Option> { + let mut violations = Vec::new(); + + // Check if contract uses Env but doesn't validate network + if source.contains("Env") || source.contains("env.") { + // Check for network passphrase validation + let has_network_validation = source.contains("get_network_passphrase") + || source.contains("network_passphrase") + || source.contains("is_testnet") + || source.contains("is_mainnet") + || source.contains("NETWORK") + || source.contains("Network") + || source.contains("env.ledger().network_passphrase()"); + + if !has_network_validation { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: "Contract uses Env but lacks network validation. This may cause issues across different Stellar networks (mainnet, testnet, futurenet).".to_string(), + suggestion: "Add network validation using `env.ledger().network_passphrase()` or check for expected network conditions. Example: `let network = env.ledger().network_passphrase(); assert!(network.to_bytes() == expected_network);`".to_string(), + line_number: 1, + column_number: 0, + variable_name: file_path.to_string(), + severity: self.severity(), + }); + } + } + + // Check for functions that interact with external systems without network checks + let lines: Vec<&str> = source.lines().collect(); + + for (i, line) in lines.iter().enumerate() { + // Look for functions that might need network validation + if (line.contains("pub fn") || line.contains("fn ")) + && (line.contains("transfer") || line.contains("withdraw") || line.contains("deposit") + || line.contains("mint") || line.contains("burn") || line.contains("swap")) { + + // Check if function has Env parameter + let has_env_param = line.contains("env: Env") || line.contains("env: &Env"); + + if has_env_param { + // Look ahead to see if there's network validation in the function + let next_lines: Vec<&str> = lines.iter().skip(i).take(20).copied().collect(); + let next_lines_str = next_lines.join("\n"); + + if !next_lines_str.contains("network_passphrase") + && !next_lines_str.contains("get_network_passphrase") + && !next_lines_str.contains("is_testnet") + && !next_lines_str.contains("is_mainnet") { + + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Function '{}' at line {} performs sensitive operations without network validation", + extract_function_name(line), i + 1), + suggestion: "Add network validation at the beginning of the function to ensure it runs on the expected network".to_string(), + line_number: i + 1, + column_number: 0, + variable_name: file_path.to_string(), + severity: ViolationSeverity::Medium, + }); + } + } + } + } + + // Check for hardcoded addresses or values that might be network-specific + if source.contains("Address::from") || source.contains("Address::generate") { + let has_network_check = source.contains("network_passphrase") + || source.contains("is_testnet") + || source.contains("is_mainnet"); + + if !has_network_check { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: "Contract creates or uses addresses without network validation. Addresses may differ across networks.".to_string(), + suggestion: "Validate the network before creating or using addresses to ensure they're valid for the current network".to_string(), + line_number: 1, + column_number: 0, + variable_name: file_path.to_string(), + severity: ViolationSeverity::Medium, + }); + } + } + + // Check for contracts that might have network-specific behavior + if source.contains("#[contractimpl]") { + let has_any_network_check = source.contains("network_passphrase") + || source.contains("ledger().network") + || source.contains("is_testnet") + || source.contains("is_mainnet") + || source.contains("NETWORK"); + + if !has_any_network_check { + // Only add this warning if we haven't already added a similar one + if !violations.iter().any(|v| v.rule_name == self.id()) { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: "Contract implementation lacks network environment validation. Consider adding checks to ensure correct behavior across Stellar networks.".to_string(), + suggestion: "Implement network validation in constructor or critical functions. Use `env.ledger().network_passphrase()` to detect the current network.".to_string(), + line_number: 1, + column_number: 0, + variable_name: file_path.to_string(), + severity: ViolationSeverity::Low, + }); + } + } + } + + if violations.is_empty() { + None + } else { + Some(violations) + } + } +} + +/// Extract function name from a function signature line +fn extract_function_name(line: &str) -> String { + if let Some(fn_start) = line.find("fn ") { + let after_fn = &line[fn_start + 3..]; + if let Some(paren_pos) = after_fn.find('(') { + return after_fn[..paren_pos].trim().to_string(); + } + } + "unknown".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detects_missing_network_validation_with_env() { + let rule = NetworkValidationRule; + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contractimpl] +impl MyContract { + pub fn do_something(env: Env) { + let timestamp = env.ledger().timestamp(); + } +} +"#; + + let violations = rule.check(source, "test.rs"); + assert!(violations.is_some()); + let violations = violations.unwrap(); + assert!(!violations.is_empty()); + + // Should detect missing network validation + let network_violation = violations.iter().find(|v| v.rule_name == "stellar-network-validation"); + assert!(network_violation.is_some()); + } + + #[test] + fn test_passes_with_network_validation() { + let rule = NetworkValidationRule; + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contractimpl] +impl MyContract { + pub fn do_something(env: Env) { + let network = env.ledger().network_passphrase(); + // Network validation present + let timestamp = env.ledger().timestamp(); + } +} +"#; + + let violations = rule.check(source, "test.rs"); + // Should have fewer or no violations since network validation is present + if let Some(viols) = violations { + let network_violations: Vec<_> = viols.iter().filter(|v| v.rule_name == "stellar-network-validation").collect(); + assert!(network_violations.is_empty() || network_violations.len() < 2); + } + } + + #[test] + fn test_detects_sensitive_function_without_network_check() { + let rule = NetworkValidationRule; + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contractimpl] +impl MyContract { + pub fn transfer(env: Env, to: Address, amount: u64) { + // Sensitive operation without network validation + } +} +"#; + + let violations = rule.check(source, "test.rs"); + assert!(violations.is_some()); + let violations = violations.unwrap(); + + // Should detect transfer function without network validation + let function_violation = violations.iter().find(|v| + v.description.contains("transfer") && v.description.contains("network validation") + ); + assert!(function_violation.is_some()); + } + + #[test] + fn test_detects_address_generation_without_network_check() { + let rule = NetworkValidationRule; + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contractimpl] +impl MyContract { + pub fn create_account(env: Env) -> Address { + Address::generate(&env) + } +} +"#; + + let violations = rule.check(source, "test.rs"); + assert!(violations.is_some()); + let violations = violations.unwrap(); + + // Should detect address generation without network validation + let address_violation = violations.iter().find(|v| + v.description.contains("addresses without network validation") + ); + assert!(address_violation.is_some()); + } + + #[test] + fn test_no_false_positives_for_safe_contract() { + let rule = NetworkValidationRule; + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contractimpl] +impl MyContract { + pub fn safe_transfer(env: Env, to: Address, amount: u64) { + let network = env.ledger().network_passphrase(); + // Validate network before proceeding + + // Perform transfer + } +} +"#; + + let violations = rule.check(source, "test.rs"); + + // Should have minimal or no violations + if let Some(viols) = violations { + let critical_violations: Vec<_> = viols.iter().filter(|v| + v.rule_name == "stellar-network-validation" + ).collect(); + // Should not flag critical violations when network check is present + assert!(critical_violations.len() <= 1); + } + } + + #[test] + fn test_extract_function_name() { + assert_eq!(extract_function_name("pub fn transfer("), "transfer"); + assert_eq!(extract_function_name("fn deposit("), "deposit"); + assert_eq!(extract_function_name("pub fn withdraw(env: Env,"), "withdraw"); + assert_eq!(extract_function_name("no function here"), "unknown"); + } +} diff --git a/packages/templates/stellar/src/__tests__/generator.spec.ts b/packages/templates/stellar/src/__tests__/generator.spec.ts index f6fc118..711f20d 100644 --- a/packages/templates/stellar/src/__tests__/generator.spec.ts +++ b/packages/templates/stellar/src/__tests__/generator.spec.ts @@ -1,14 +1,14 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { SorobanTemplateGenerator, TEMPLATE_REGISTRY } from '../generator'; -import { TemplateValidator } from '../validator'; -import { TemplateKind } from '../types'; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { SorobanTemplateGenerator, TEMPLATE_REGISTRY } from "../generator"; +import { TemplateValidator } from "../validator"; +import { TemplateKind } from "../types"; // ── Helpers ─────────────────────────────────────────────────────────────────── function makeTmpDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gasguard-templates-')); + return fs.mkdtempSync(path.join(os.tmpdir(), "gasguard-templates-")); } function cleanup(dir: string) { @@ -17,15 +17,15 @@ function cleanup(dir: string) { // ── Registry ────────────────────────────────────────────────────────────────── -describe('TEMPLATE_REGISTRY', () => { - it('contains all four template kinds', () => { - const kinds: TemplateKind[] = ['token', 'counter', 'nft', 'multisig']; +describe("TEMPLATE_REGISTRY", () => { + it("contains all four template kinds", () => { + const kinds: TemplateKind[] = ["token", "counter", "nft", "multisig"]; for (const kind of kinds) { expect(TEMPLATE_REGISTRY[kind]).toBeDefined(); } }); - it('every entry has required metadata fields', () => { + it("every entry has required metadata fields", () => { for (const meta of Object.values(TEMPLATE_REGISTRY)) { expect(meta.kind).toBeDefined(); expect(meta.title).toBeTruthy(); @@ -39,55 +39,55 @@ describe('TEMPLATE_REGISTRY', () => { // ── Generator: validation ───────────────────────────────────────────────────── -describe('SorobanTemplateGenerator – contract name validation', () => { +describe("SorobanTemplateGenerator – contract name validation", () => { const generator = new SorobanTemplateGenerator(); it.each([ - ['', 'empty string'], - [' ', 'whitespace only'], - ['myToken', 'starts with lowercase'], - ['My Token', 'contains space'], - ['My-Token', 'contains hyphen'], - ['123Token', 'starts with digit'], - ['A'.repeat(65), 'exceeds 64 characters'], + ["", "empty string"], + [" ", "whitespace only"], + ["myToken", "starts with lowercase"], + ["My Token", "contains space"], + ["My-Token", "contains hyphen"], + ["123Token", "starts with digit"], + ["A".repeat(65), "exceeds 64 characters"], ])('rejects "%s" (%s)', (name) => { - expect(() => generator.preview('token', name)).toThrow(); + expect(() => generator.preview("token", name)).toThrow(); }); it.each([ - ['MyToken', 'standard PascalCase'], - ['Token', 'single word PascalCase'], - ['MyNFTContract', 'acronym in middle'], - ['A', 'single uppercase letter'], + ["MyToken", "standard PascalCase"], + ["Token", "single word PascalCase"], + ["MyNFTContract", "acronym in middle"], + ["A", "single uppercase letter"], ])('accepts "%s" (%s)', (name) => { - expect(() => generator.preview('token', name)).not.toThrow(); + expect(() => generator.preview("token", name)).not.toThrow(); }); }); // ── Generator: preview ──────────────────────────────────────────────────────── -describe('SorobanTemplateGenerator.preview()', () => { +describe("SorobanTemplateGenerator.preview()", () => { const generator = new SorobanTemplateGenerator(); - it.each(['token', 'counter', 'nft', 'multisig'])( - 'renders %s template without placeholders', + it.each(["token", "counter", "nft", "multisig"])( + "renders %s template without placeholders", (kind) => { - const source = generator.preview(kind, 'MyContract'); - expect(source).not.toContain('{{CONTRACT_NAME}}'); - expect(source).toContain('MyContract'); + const source = generator.preview(kind, "MyContract"); + expect(source).not.toContain("{{CONTRACT_NAME}}"); + expect(source).toContain("MyContract"); }, ); - it('throws for an unknown template kind', () => { + it("throws for an unknown template kind", () => { expect(() => - generator.preview('unknown' as TemplateKind, 'MyContract'), + generator.preview("unknown" as TemplateKind, "MyContract"), ).toThrow(/Unknown template kind/); }); }); // ── Generator: file writing ─────────────────────────────────────────────────── -describe('SorobanTemplateGenerator.generate()', () => { +describe("SorobanTemplateGenerator.generate()", () => { let tmpDir: string; const generator = new SorobanTemplateGenerator(); @@ -99,58 +99,70 @@ describe('SorobanTemplateGenerator.generate()', () => { cleanup(tmpDir); }); - it.each(['token', 'counter', 'nft', 'multisig'])( - 'generates a %s contract file', + it.each(["token", "counter", "nft", "multisig"])( + "generates a %s contract file", (kind) => { const result = generator.generate({ kind, - contractName: 'MyContract', + contractName: "MyContract", outputDir: tmpDir, }); expect(fs.existsSync(result.filePath)).toBe(true); expect(result.kind).toBe(kind); - expect(result.contractName).toBe('MyContract'); + expect(result.contractName).toBe("MyContract"); - const content = fs.readFileSync(result.filePath, 'utf-8'); - expect(content).toContain('MyContract'); - expect(content).not.toContain('{{CONTRACT_NAME}}'); + const content = fs.readFileSync(result.filePath, "utf-8"); + expect(content).toContain("MyContract"); + expect(content).not.toContain("{{CONTRACT_NAME}}"); }, ); - it('names the output file using snake_case', () => { + it("names the output file using snake_case", () => { const result = generator.generate({ - kind: 'token', - contractName: 'MyFancyToken', + kind: "token", + contractName: "MyFancyToken", outputDir: tmpDir, }); - expect(path.basename(result.filePath)).toBe('my_fancy_token.rs'); + expect(path.basename(result.filePath)).toBe("my_fancy_token.rs"); }); - it('throws when file already exists and overwrite is false', () => { - generator.generate({ kind: 'token', contractName: 'MyToken', outputDir: tmpDir }); + it("throws when file already exists and overwrite is false", () => { + generator.generate({ + kind: "token", + contractName: "MyToken", + outputDir: tmpDir, + }); expect(() => - generator.generate({ kind: 'token', contractName: 'MyToken', outputDir: tmpDir }), + generator.generate({ + kind: "token", + contractName: "MyToken", + outputDir: tmpDir, + }), ).toThrow(/already exists/); }); - it('overwrites an existing file when overwrite is true', () => { - generator.generate({ kind: 'token', contractName: 'MyToken', outputDir: tmpDir }); + it("overwrites an existing file when overwrite is true", () => { + generator.generate({ + kind: "token", + contractName: "MyToken", + outputDir: tmpDir, + }); expect(() => generator.generate({ - kind: 'token', - contractName: 'MyToken', + kind: "token", + contractName: "MyToken", outputDir: tmpDir, overwrite: true, }), ).not.toThrow(); }); - it('creates the output directory when it does not exist', () => { - const nested = path.join(tmpDir, 'a', 'b', 'c'); + it("creates the output directory when it does not exist", () => { + const nested = path.join(tmpDir, "a", "b", "c"); const result = generator.generate({ - kind: 'counter', - contractName: 'MyCounter', + kind: "counter", + contractName: "MyCounter", outputDir: nested, }); expect(fs.existsSync(result.filePath)).toBe(true); @@ -159,23 +171,23 @@ describe('SorobanTemplateGenerator.generate()', () => { // ── Validator ───────────────────────────────────────────────────────────────── -describe('TemplateValidator', () => { +describe("TemplateValidator", () => { const generator = new SorobanTemplateGenerator(); const validator = new TemplateValidator(); - it.each(['token', 'counter', 'nft', 'multisig'])( - 'rendered %s template passes validation with no errors', + it.each(["token", "counter", "nft", "multisig"])( + "rendered %s template passes validation with no errors", (kind) => { - const source = generator.preview(kind, 'ValidContract'); + const source = generator.preview(kind, "ValidContract"); const result = validator.validate(source); - const errors = result.issues.filter((i) => i.severity === 'error'); + const errors = result.issues.filter((i) => i.severity === "error"); expect(errors).toHaveLength(0); expect(result.valid).toBe(true); }, ); - it('flags unresolved placeholder as an error', () => { + it("flags unresolved placeholder as an error", () => { const source = ` #![no_std] use soroban_sdk::{contract, contractimpl}; @@ -189,13 +201,15 @@ impl {{CONTRACT_NAME}} { } `; const result = validator.validate(source); - const issue = result.issues.find((i) => i.code === 'UNRESOLVED_PLACEHOLDER'); + const issue = result.issues.find( + (i) => i.code === "UNRESOLVED_PLACEHOLDER", + ); expect(issue).toBeDefined(); - expect(issue?.severity).toBe('error'); + expect(issue?.severity).toBe("error"); expect(result.valid).toBe(false); }); - it('flags missing require_auth', () => { + it("flags missing require_auth", () => { const source = ` #![no_std] use soroban_sdk::{contract, contractimpl, contracttype}; @@ -209,13 +223,13 @@ impl MyCounter { } `; const result = validator.validate(source); - const issue = result.issues.find((i) => i.code === 'REQUIRE_AUTH_MISSING'); + const issue = result.issues.find((i) => i.code === "REQUIRE_AUTH_MISSING"); expect(issue).toBeDefined(); - expect(issue?.severity).toBe('error'); + expect(issue?.severity).toBe("error"); expect(result.valid).toBe(false); }); - it('flags missing #[contract] macro', () => { + it("flags missing #[contract] macro", () => { const source = ` #![no_std] use soroban_sdk::{contractimpl}; @@ -226,20 +240,21 @@ impl NoMacro { } `; const result = validator.validate(source); - const issue = result.issues.find((i) => i.code === 'CONTRACT_MACRO_MISSING'); + const issue = result.issues.find( + (i) => i.code === "CONTRACT_MACRO_MISSING", + ); expect(issue).toBeDefined(); expect(result.valid).toBe(false); }); - it('warns about use of .unwrap()', () => { - const source = generator.preview('counter', 'SafeCounter').replace( - '.expect("already initialized")', - '.unwrap()', - ); + it("warns about use of .unwrap()", () => { + const source = generator + .preview("counter", "SafeCounter") + .replace('.expect("already initialized")', ".unwrap()"); const result = validator.validate(source); - const issue = result.issues.find((i) => i.code === 'UNSAFE_UNWRAP'); + const issue = result.issues.find((i) => i.code === "UNSAFE_UNWRAP"); expect(issue).toBeDefined(); - expect(issue?.severity).toBe('warning'); + expect(issue?.severity).toBe("warning"); // warnings alone do not mark the template invalid expect(result.valid).toBe(true); }); @@ -247,23 +262,23 @@ impl NoMacro { // ── listTemplates / getMetadata ─────────────────────────────────────────────── -describe('SorobanTemplateGenerator – catalogue helpers', () => { +describe("SorobanTemplateGenerator – catalogue helpers", () => { const generator = new SorobanTemplateGenerator(); - it('listTemplates() returns four entries', () => { + it("listTemplates() returns four entries", () => { expect(generator.listTemplates()).toHaveLength(4); }); - it('getMetadata() returns correct metadata for each kind', () => { + it("getMetadata() returns correct metadata for each kind", () => { for (const kind of Object.keys(TEMPLATE_REGISTRY) as TemplateKind[]) { const meta = generator.getMetadata(kind); expect(meta.kind).toBe(kind); } }); - it('getMetadata() throws for unknown kind', () => { - expect(() => - generator.getMetadata('unknown' as TemplateKind), - ).toThrow(/Unknown template kind/); + it("getMetadata() throws for unknown kind", () => { + expect(() => generator.getMetadata("unknown" as TemplateKind)).toThrow( + /Unknown template kind/, + ); }); }); diff --git a/packages/templates/stellar/src/generator.ts b/packages/templates/stellar/src/generator.ts index 480e4a2..ea41fdb 100644 --- a/packages/templates/stellar/src/generator.ts +++ b/packages/templates/stellar/src/generator.ts @@ -1,76 +1,76 @@ -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; import { GeneratorOptions, GenerateResult, TemplateKind, TemplateMetadata, -} from './types'; +} from "./types"; // ── Template metadata registry ──────────────────────────────────────────────── /** Catalogue of all available Soroban contract templates. */ export const TEMPLATE_REGISTRY: Record = { token: { - kind: 'token', - title: 'Fungible Token (SEP-41)', + kind: "token", + title: "Fungible Token (SEP-41)", description: - 'A production-ready fungible token contract with minting, burning, transfers, and allowances.', + "A production-ready fungible token contract with minting, burning, transfers, and allowances.", secureDefaults: [ - 'Admin-gated minting and role transfer', - 'require_auth on every state-mutating call', - 'Overflow-safe arithmetic with checked_add / checked_sub', - 'Re-initialisation guard (panics if already initialised)', - 'Self-transfer guard on transfer / transfer_from', - 'Event emission for mint, burn, transfer, and approve', - 'Allowance stored in temporary storage (auto-expiring)', + "Admin-gated minting and role transfer", + "require_auth on every state-mutating call", + "Overflow-safe arithmetic with checked_add / checked_sub", + "Re-initialisation guard (panics if already initialised)", + "Self-transfer guard on transfer / transfer_from", + "Event emission for mint, burn, transfer, and approve", + "Allowance stored in temporary storage (auto-expiring)", ], - fileName: 'token.rs', + fileName: "token.rs", }, counter: { - kind: 'counter', - title: 'Counter', + kind: "counter", + title: "Counter", description: - 'A minimal admin-gated counter demonstrating instance storage, overflow protection, and step configuration.', + "A minimal admin-gated counter demonstrating instance storage, overflow protection, and step configuration.", secureDefaults: [ - 'Admin-gated increment, decrement, and reset', - 'Overflow / underflow protection via checked arithmetic', - 'Configurable step size with minimum bound enforcement', - 'Re-initialisation guard', - 'Event emission on every mutation', + "Admin-gated increment, decrement, and reset", + "Overflow / underflow protection via checked arithmetic", + "Configurable step size with minimum bound enforcement", + "Re-initialisation guard", + "Event emission on every mutation", ], - fileName: 'counter.rs', + fileName: "counter.rs", }, nft: { - kind: 'nft', - title: 'Non-Fungible Token (NFT)', + kind: "nft", + title: "Non-Fungible Token (NFT)", description: - 'An NFT contract with mint, transfer, per-token approval, and operator approval.', + "An NFT contract with mint, transfer, per-token approval, and operator approval.", secureDefaults: [ - 'Admin-gated minting', - 'Owner-only transfer and approval', - 'Per-token approval cleared on transfer', - 'Overflow-safe sequential token ID generation', - 'Existence checks on all token operations', - 'Event emission for mint, transfer, approve, and operator approval', + "Admin-gated minting", + "Owner-only transfer and approval", + "Per-token approval cleared on transfer", + "Overflow-safe sequential token ID generation", + "Existence checks on all token operations", + "Event emission for mint, transfer, approve, and operator approval", ], - fileName: 'nft.rs', + fileName: "nft.rs", }, multisig: { - kind: 'multisig', - title: 'Multi-Signature Wallet', + kind: "multisig", + title: "Multi-Signature Wallet", description: - 'An M-of-N multisig wallet with proposal lifecycle: create, approve, revoke, and execute.', + "An M-of-N multisig wallet with proposal lifecycle: create, approve, revoke, and execute.", secureDefaults: [ - 'Signer uniqueness enforced at initialisation', - 'Threshold bounds checked (1 ≤ threshold ≤ signers count)', - 'Proposal expiry via ledger sequence comparison', - 'Executed flag set before external invocation (re-entrancy guard)', - 'Duplicate-approval prevention', - 'require_auth on every state-mutating call', - 'Event emission for propose, approve, revoke, and execute', + "Signer uniqueness enforced at initialisation", + "Threshold bounds checked (1 ≤ threshold ≤ signers count)", + "Proposal expiry via ledger sequence comparison", + "Executed flag set before external invocation (re-entrancy guard)", + "Duplicate-approval prevention", + "require_auth on every state-mutating call", + "Event emission for propose, approve, revoke, and execute", ], - fileName: 'multisig.rs', + fileName: "multisig.rs", }, }; @@ -88,7 +88,7 @@ export class SorobanTemplateGenerator { constructor(templatesDir?: string) { this.templatesDir = - templatesDir ?? path.resolve(__dirname, '..', 'templates'); + templatesDir ?? path.resolve(__dirname, "..", "templates"); } // ─── Public API ───────────────────────────────────────────────────── @@ -109,7 +109,7 @@ export class SorobanTemplateGenerator { const metadata = TEMPLATE_REGISTRY[kind]; if (!metadata) { throw new Error( - `Unknown template kind "${kind}". Available: ${Object.keys(TEMPLATE_REGISTRY).join(', ')}.`, + `Unknown template kind "${kind}". Available: ${Object.keys(TEMPLATE_REGISTRY).join(", ")}.`, ); } @@ -134,7 +134,7 @@ export class SorobanTemplateGenerator { ); } - fs.writeFileSync(filePath, rendered, 'utf-8'); + fs.writeFileSync(filePath, rendered, "utf-8"); return { filePath, kind, contractName }; } @@ -180,11 +180,11 @@ export class SorobanTemplateGenerator { if (!fs.existsSync(templatePath)) { throw new Error(`Template file not found: ${templatePath}`); } - return fs.readFileSync(templatePath, 'utf-8'); + return fs.readFileSync(templatePath, "utf-8"); } private renderTemplate(template: string, contractName: string): string { - return template.split('{{CONTRACT_NAME}}').join(contractName); + return template.split("{{CONTRACT_NAME}}").join(contractName); } /** @@ -193,7 +193,7 @@ export class SorobanTemplateGenerator { */ private validateContractName(name: string): void { if (!name || name.trim().length === 0) { - throw new Error('Contract name must not be empty.'); + throw new Error("Contract name must not be empty."); } if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) { throw new Error( @@ -201,7 +201,7 @@ export class SorobanTemplateGenerator { ); } if (name.length > 64) { - throw new Error('Contract name must be 64 characters or fewer.'); + throw new Error("Contract name must be 64 characters or fewer."); } } @@ -211,6 +211,6 @@ export class SorobanTemplateGenerator { .replace(/([A-Z])/g, (match, letter, offset) => offset === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`, ) - .replace(/__+/g, '_'); + .replace(/__+/g, "_"); } } diff --git a/packages/templates/stellar/src/index.ts b/packages/templates/stellar/src/index.ts index 22b4d2e..24a1e95 100644 --- a/packages/templates/stellar/src/index.ts +++ b/packages/templates/stellar/src/index.ts @@ -1,5 +1,5 @@ -export { SorobanTemplateGenerator, TEMPLATE_REGISTRY } from './generator'; -export { TemplateValidator } from './validator'; +export { SorobanTemplateGenerator, TEMPLATE_REGISTRY } from "./generator"; +export { TemplateValidator } from "./validator"; export type { TemplateKind, TemplateMetadata, @@ -7,4 +7,4 @@ export type { GenerateResult, ValidationIssue, ValidationResult, -} from './types'; +} from "./types"; diff --git a/packages/templates/stellar/src/types.ts b/packages/templates/stellar/src/types.ts index 8acccac..3be3a25 100644 --- a/packages/templates/stellar/src/types.ts +++ b/packages/templates/stellar/src/types.ts @@ -1,5 +1,5 @@ /** Available Soroban contract template types. */ -export type TemplateKind = 'token' | 'counter' | 'nft' | 'multisig'; +export type TemplateKind = "token" | "counter" | "nft" | "multisig"; /** Options passed to the template generator. */ export interface GeneratorOptions { @@ -43,7 +43,7 @@ export interface ValidationIssue { /** Human-readable message. */ message: string; /** Severity level. */ - severity: 'error' | 'warning' | 'info'; + severity: "error" | "warning" | "info"; } /** Validation result returned by the template validator. */ diff --git a/packages/templates/stellar/src/validator.ts b/packages/templates/stellar/src/validator.ts index b88e68c..b3362fd 100644 --- a/packages/templates/stellar/src/validator.ts +++ b/packages/templates/stellar/src/validator.ts @@ -1,4 +1,4 @@ -import { ValidationIssue, ValidationResult } from './types'; +import { ValidationIssue, ValidationResult } from "./types"; // ── Security patterns ───────────────────────────────────────────────────────── @@ -9,38 +9,38 @@ const REQUIRED_PATTERNS: Array<{ pattern: RegExp; code: string; message: string; - severity: ValidationIssue['severity']; + severity: ValidationIssue["severity"]; }> = [ { pattern: /#!\[no_std\]/, - code: 'NO_STD_MISSING', - message: 'Soroban contracts must declare `#![no_std]`.', - severity: 'error', + code: "NO_STD_MISSING", + message: "Soroban contracts must declare `#![no_std]`.", + severity: "error", }, { pattern: /use soroban_sdk::/, - code: 'SOROBAN_SDK_MISSING', - message: 'Contract must import from `soroban_sdk`.', - severity: 'error', + code: "SOROBAN_SDK_MISSING", + message: "Contract must import from `soroban_sdk`.", + severity: "error", }, { pattern: /#\[contract\]/, - code: 'CONTRACT_MACRO_MISSING', - message: 'Contract struct must be annotated with `#[contract]`.', - severity: 'error', + code: "CONTRACT_MACRO_MISSING", + message: "Contract struct must be annotated with `#[contract]`.", + severity: "error", }, { pattern: /#\[contractimpl\]/, - code: 'CONTRACTIMPL_MISSING', - message: 'Implementation block must be annotated with `#[contractimpl]`.', - severity: 'error', + code: "CONTRACTIMPL_MISSING", + message: "Implementation block must be annotated with `#[contractimpl]`.", + severity: "error", }, { pattern: /\.require_auth\(\)/, - code: 'REQUIRE_AUTH_MISSING', + code: "REQUIRE_AUTH_MISSING", message: - 'No `require_auth()` call found. All state-mutating functions must authorise the caller.', - severity: 'error', + "No `require_auth()` call found. All state-mutating functions must authorise the caller.", + severity: "error", }, ]; @@ -51,37 +51,37 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; code: string; message: string; - severity: ValidationIssue['severity']; + severity: ValidationIssue["severity"]; }> = [ { pattern: /\bunwrap\(\)/, - code: 'UNSAFE_UNWRAP', + code: "UNSAFE_UNWRAP", message: 'Avoid `.unwrap()` — use `.expect("…")` with a descriptive message or handle the `None` case explicitly.', - severity: 'warning', + severity: "warning", }, { pattern: /panic!\(\s*"[^"]*"\s*\)/, - code: 'BARE_PANIC', + code: "BARE_PANIC", message: - 'Prefer `assert!` or structured error types over bare `panic!` for better diagnostics.', - severity: 'info', + "Prefer `assert!` or structured error types over bare `panic!` for better diagnostics.", + severity: "info", }, { // Detect raw integer arithmetic that could overflow (+, -, *) without // checked_* or saturating_* equivalents, only for i128/u64/u32 literals. pattern: /\b(i128|u64|u32|u128)\b[^;]*[^._][\+\-\*][^=][^;]*;/, - code: 'UNCHECKED_ARITHMETIC', + code: "UNCHECKED_ARITHMETIC", message: - 'Potential unchecked arithmetic detected. Prefer `checked_add`, `checked_sub`, or `saturating_*` methods.', - severity: 'warning', + "Potential unchecked arithmetic detected. Prefer `checked_add`, `checked_sub`, or `saturating_*` methods.", + severity: "warning", }, { pattern: /std::collections::/, - code: 'STD_COLLECTIONS', + code: "STD_COLLECTIONS", message: - 'Do not use `std::collections`. Use Soroban SDK types (`Map`, `Vec`) instead.', - severity: 'error', + "Do not use `std::collections`. Use Soroban SDK types (`Map`, `Vec`) instead.", + severity: "error", }, ]; @@ -95,15 +95,15 @@ const RECOMMENDED_PATTERNS: Array<{ }> = [ { pattern: /env\.events\(\)\.publish/, - code: 'NO_EVENTS', + code: "NO_EVENTS", message: - 'No event emission detected. Consider publishing events for on-chain observability.', + "No event emission detected. Consider publishing events for on-chain observability.", }, { pattern: /#\[contracttype\]/, - code: 'NO_CONTRACTTYPE', + code: "NO_CONTRACTTYPE", message: - 'No `#[contracttype]` annotation found. Structured data types improve SDK interoperability.', + "No `#[contracttype]` annotation found. Structured data types improve SDK interoperability.", }, ]; @@ -151,22 +151,22 @@ export class TemplateValidator { issues.push({ code: check.code, message: check.message, - severity: 'info', + severity: "info", }); } } // 4. Placeholder check – the generator should have substituted all tokens - if (source.includes('{{CONTRACT_NAME}}')) { + if (source.includes("{{CONTRACT_NAME}}")) { issues.push({ - code: 'UNRESOLVED_PLACEHOLDER', + code: "UNRESOLVED_PLACEHOLDER", message: - '`{{CONTRACT_NAME}}` placeholder was not replaced. Run the generator before validating.', - severity: 'error', + "`{{CONTRACT_NAME}}` placeholder was not replaced. Run the generator before validating.", + severity: "error", }); } - const hasErrors = issues.some((i) => i.severity === 'error'); + const hasErrors = issues.some((i) => i.severity === "error"); return { valid: !hasErrors, diff --git a/src/simulation/stellar/__tests__/stellar-simulator.spec.ts b/src/simulation/stellar/__tests__/stellar-simulator.spec.ts new file mode 100644 index 0000000..3896faf --- /dev/null +++ b/src/simulation/stellar/__tests__/stellar-simulator.spec.ts @@ -0,0 +1,558 @@ +/** + * Stellar Transaction Simulator Tests + */ + +import { StellarTransactionSimulator } from "../stellar-simulator"; +import { StellarRpcClient } from "../stellar-rpc-client"; +import { StellarSimulationRequest, StellarSimulationResult } from "../types"; + +// Mock the StellarRpcClient +jest.mock("../stellar-rpc-client"); + +describe("StellarTransactionSimulator", () => { + let simulator: StellarTransactionSimulator; + const testRpcUrl = "https://soroban-testnet.stellar.org"; + const testContractId = + "CDLZVWRQK6QZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF"; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + simulator = new StellarTransactionSimulator(testRpcUrl); + }); + + describe("constructor", () => { + it("should create an instance with default config", () => { + expect(simulator).toBeDefined(); + }); + + it("should create an instance with custom config", () => { + const customSimulator = new StellarTransactionSimulator(testRpcUrl, { + timeout: 5000, + maxRetries: 5, + }); + expect(customSimulator).toBeDefined(); + }); + }); + + describe("simulateContractCall", () => { + const mockRequest: StellarSimulationRequest = { + contractId: testContractId, + method: "transfer", + params: ["alice", "bob", 1000], + rpcUrl: testRpcUrl, + }; + + it("should successfully simulate a contract call", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [ + { + contract_id: testContractId, + type: "system", + topics: ["transfer"], + value: { amount: 1000 }, + }, + ], + results: [ + { + auth: [], + returnValue: "success", + }, + ], + transactionData: { + resources: { + footprint: { + readOnly: ["ContractData(balance_alice)"], + readWrite: ["ContractData(balance_bob)"], + }, + instructions: 1_250_000, + readBytes: 512, + writeBytes: 256, + }, + transactionSizeBytes: 4096, + }, + minResourceFee: "150000", + }, + latestLedger: 123456, + transactionEnvelope: "AAAAAA==", + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall(mockRequest); + + expect(result.success).toBe(true); + expect(result.metrics.instructions).toBe(1_250_000); + expect(result.metrics.ledgerReads).toBe(1); + expect(result.metrics.ledgerWrites).toBe(1); + expect(result.metrics.eventCount).toBe(1); + expect(result.metrics.authCount).toBe(0); + expect(result.events.length).toBe(1); + expect(result.latestLedger).toBe(123456); + expect(result.transactionEnvelope).toBe("AAAAAA=="); + }); + + it("should handle simulation failure", async () => { + const mockRpcResponse = { + result: { + success: false, + error: "Contract execution failed: insufficient balance", + auth: [], + events: [], + transactionData: { + resources: { + footprint: { + readOnly: [], + readWrite: [], + }, + instructions: 0, + readBytes: 0, + writeBytes: 0, + }, + transactionSizeBytes: 0, + }, + minResourceFee: "0", + }, + latestLedger: 123456, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall(mockRequest); + + expect(result.success).toBe(false); + expect(result.error).toContain("insufficient balance"); + expect(result.metrics.instructions).toBe(0); + }); + + it("should handle RPC errors gracefully", async () => { + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest + .fn() + .mockRejectedValue(new Error("Network error")), + })); + + const result = await simulator.simulateContractCall(mockRequest); + + expect(result.success).toBe(false); + expect(result.error).toContain("Network error"); + expect(result.metrics.instructions).toBe(0); + }); + + it("should extract comprehensive execution metrics", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [ + { address: "alice", nonce: 123 }, + { address: "bob", nonce: 456 }, + ], + events: [ + { + contract_id: testContractId, + type: "system", + topics: ["transfer"], + }, + { + contract_id: testContractId, + type: "system", + topics: ["approve"], + }, + ], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { + readOnly: [ + "ContractData(balance_alice)", + "ContractData(token_metadata)", + ], + readWrite: [ + "ContractData(balance_bob)", + "ContractData(balance_alice)", + ], + }, + instructions: 2_500_000, + readBytes: 1024, + writeBytes: 512, + }, + transactionSizeBytes: 8192, + }, + minResourceFee: "250000", + }, + latestLedger: 123457, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall(mockRequest); + + expect(result.success).toBe(true); + expect(result.metrics.instructions).toBe(2_500_000); + expect(result.metrics.ledgerReads).toBe(2); + expect(result.metrics.ledgerWrites).toBe(2); + expect(result.metrics.readBytes).toBe(1024); + expect(result.metrics.writeBytes).toBe(512); + expect(result.metrics.transactionSizeBytes).toBe(8192); + expect(result.metrics.eventCount).toBe(2); + expect(result.metrics.authCount).toBe(2); + expect(result.metrics.minResourceFee).toBe(250000); + expect(result.metrics.memoryBytes).toBeGreaterThan(0); + expect(result.metrics.executionTime).toBeGreaterThanOrEqual(0); + }); + + it("should handle empty simulation results", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [], + transactionData: { + resources: { + footprint: { + readOnly: [], + readWrite: [], + }, + instructions: 0, + readBytes: 0, + writeBytes: 0, + }, + transactionSizeBytes: 0, + }, + minResourceFee: "0", + }, + latestLedger: 123458, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall(mockRequest); + + expect(result.success).toBe(true); + expect(result.metrics.instructions).toBe(0); + expect(result.events).toEqual([]); + expect(result.authEntries).toEqual([]); + }); + }); + + describe("simulateBatch", () => { + it("should simulate multiple contract calls in parallel", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { + readOnly: ["ContractData(balance)"], + readWrite: [], + }, + instructions: 500_000, + readBytes: 256, + writeBytes: 0, + }, + transactionSizeBytes: 2048, + }, + minResourceFee: "100000", + }, + latestLedger: 123459, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const requests: StellarSimulationRequest[] = [ + { + contractId: testContractId, + method: "transfer", + params: ["alice", "bob", 100], + rpcUrl: testRpcUrl, + }, + { + contractId: testContractId, + method: "transfer", + params: ["charlie", "dave", 200], + rpcUrl: testRpcUrl, + }, + { + contractId: testContractId, + method: "balance", + params: ["alice"], + rpcUrl: testRpcUrl, + }, + ]; + + const results = await simulator.simulateBatch(requests); + + expect(results).toHaveLength(3); + expect(results.every((r: StellarSimulationResult) => r.success)).toBe( + true, + ); + expect( + results.every( + (r: StellarSimulationResult) => r.metrics.instructions > 0, + ), + ).toBe(true); + }); + + it("should handle mixed success and failure in batch", async () => { + const successResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { readOnly: [], readWrite: [] }, + instructions: 500_000, + readBytes: 0, + writeBytes: 0, + }, + transactionSizeBytes: 1024, + }, + minResourceFee: "50000", + }, + latestLedger: 123460, + }; + + const failureResponse = { + result: { + success: false, + error: "Method not found", + auth: [], + events: [], + transactionData: { + resources: { + footprint: { readOnly: [], readWrite: [] }, + instructions: 0, + readBytes: 0, + writeBytes: 0, + }, + transactionSizeBytes: 0, + }, + minResourceFee: "0", + }, + latestLedger: 123460, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest + .fn() + .mockResolvedValueOnce(successResponse) + .mockResolvedValueOnce(failureResponse), + })); + + const requests: StellarSimulationRequest[] = [ + { + contractId: testContractId, + method: "transfer", + params: ["alice", "bob", 100], + rpcUrl: testRpcUrl, + }, + { + contractId: testContractId, + method: "invalid_method", + params: [], + rpcUrl: testRpcUrl, + }, + ]; + + const results = await simulator.simulateBatch(requests); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(false); + expect(results[1].error).toContain("Method not found"); + }); + }); + + describe("Edge Cases", () => { + it("should handle large instruction counts", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { + readOnly: Array(40).fill("ContractData(entry)"), + readWrite: Array(25).fill("ContractData(entry)"), + }, + instructions: 95_000_000, // Close to limit + readBytes: 190_000, + writeBytes: 95_000, + }, + transactionSizeBytes: 95_000, + }, + minResourceFee: "5000000", + }, + latestLedger: 123461, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall({ + contractId: testContractId, + method: "batch_transfer", + params: Array(100).fill("recipient"), + rpcUrl: testRpcUrl, + }); + + expect(result.success).toBe(true); + expect(result.metrics.instructions).toBe(95_000_000); + expect(result.metrics.ledgerReads).toBe(40); + expect(result.metrics.ledgerWrites).toBe(25); + }); + + it("should handle minimal transactions", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { + readOnly: ["ContractData(counter)"], + readWrite: [], + }, + instructions: 10_000, + readBytes: 64, + writeBytes: 0, + }, + transactionSizeBytes: 512, + }, + minResourceFee: "10000", + }, + latestLedger: 123462, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall({ + contractId: testContractId, + method: "get_counter", + params: [], + rpcUrl: testRpcUrl, + }); + + expect(result.success).toBe(true); + expect(result.metrics.instructions).toBe(10_000); + expect(result.metrics.ledgerReads).toBe(1); + expect(result.metrics.ledgerWrites).toBe(0); + }); + + it("should handle complex parameter types", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { readOnly: [], readWrite: [] }, + instructions: 100_000, + readBytes: 0, + writeBytes: 0, + }, + transactionSizeBytes: 1024, + }, + minResourceFee: "50000", + }, + latestLedger: 123463, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const complexParams = [ + "string_value", + 12345, + BigInt(999999999999), + ["array", "of", "values"], + { key1: "value1", key2: "value2" }, + Buffer.from("binary_data"), + ]; + + const result = await simulator.simulateContractCall({ + contractId: testContractId, + method: "complex_function", + params: complexParams, + rpcUrl: testRpcUrl, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("Metrics Estimation", () => { + it("should estimate memory usage correctly", async () => { + const mockRpcResponse = { + result: { + success: true, + auth: [], + events: [], + results: [{ auth: [], returnValue: "success" }], + transactionData: { + resources: { + footprint: { + readOnly: ["ContractData(a)", "ContractData(b)"], + readWrite: ["ContractData(c)"], + }, + instructions: 1_000_000, + readBytes: 1024, + writeBytes: 512, + }, + transactionSizeBytes: 2048, + }, + minResourceFee: "100000", + }, + latestLedger: 123464, + }; + + (StellarRpcClient as jest.Mock).mockImplementation(() => ({ + simulateTransaction: jest.fn().mockResolvedValue(mockRpcResponse), + })); + + const result = await simulator.simulateContractCall({ + contractId: testContractId, + method: "test", + params: [], + rpcUrl: testRpcUrl, + }); + + // Memory should be estimated based on: + // - Base 1MB + // - 3 entries * 4KB = 12KB + // - (1024 + 512) * 2 = 3KB + // - 1_000_000 * 10 = ~9.5MB + expect(result.metrics.memoryBytes).toBeGreaterThan(10_000_000); + expect(result.metrics.memoryBytes).toBeLessThan(15_000_000); + }); + }); +}); diff --git a/src/simulation/stellar/index.ts b/src/simulation/stellar/index.ts new file mode 100644 index 0000000..7547547 --- /dev/null +++ b/src/simulation/stellar/index.ts @@ -0,0 +1,22 @@ +/** + * Stellar Transaction Simulation Module + * + * Provides comprehensive transaction simulation capabilities for Stellar/Soroban + * smart contracts, allowing developers to preview execution behavior and analyze + * resource consumption before submitting transactions to the network. + */ + +export { StellarTransactionSimulator } from "./stellar-simulator"; +export { StellarRpcClient } from "./stellar-rpc-client"; +export type { + StellarSimulationRequest, + StellarSimulationResult, + ExecutionMetrics, + SimulationConfig, + ContractCallSpec, + SorobanRpcSimulationResponse, + SorobanSimulationResult, + SorobanOperationResult, + SorobanTransactionData, + SorobanResources, +} from "./types"; diff --git a/src/simulation/stellar/stellar-rpc-client.ts b/src/simulation/stellar/stellar-rpc-client.ts new file mode 100644 index 0000000..e7a15ac --- /dev/null +++ b/src/simulation/stellar/stellar-rpc-client.ts @@ -0,0 +1,199 @@ +/** + * Stellar RPC Client for Soroban + * + * Provides a type-safe interface for communicating with Soroban RPC endpoints, + * specifically for transaction simulation. + */ + +import axios, { AxiosInstance, AxiosError } from "axios"; +import { SimulationConfig } from "./types"; + +/** + * JSON-RPC 2.0 request structure + */ +interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params: any[]; + id: number; +} + +/** + * JSON-RPC 2.0 response structure + */ +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number | null; + result?: T; + error?: { + code: number; + message: string; + data?: any; + }; +} + +/** + * Soroban RPC Client + * + * Handles communication with Soroban RPC endpoints with built-in + * retry logic, timeout handling, and error management. + */ +export class StellarRpcClient { + private client: AxiosInstance; + private config: Required; + private requestIdCounter: number = 0; + + constructor(rpcUrl: string, config?: SimulationConfig) { + this.config = { + timeout: config?.timeout ?? 30000, + maxRetries: config?.maxRetries ?? 3, + includeTrace: config?.includeTrace ?? false, + headers: config?.headers ?? {}, + }; + + this.client = axios.create({ + baseURL: rpcUrl, + timeout: this.config.timeout, + headers: { + "Content-Type": "application/json", + ...this.config.headers, + }, + }); + } + + /** + * Simulate a transaction on the Soroban network + * + * @param transactionXdr - Transaction envelope XDR to simulate + * @returns Promise resolving to simulation result + */ + async simulateTransaction( + transactionXdr: string, + ): Promise> { + return this.callRpcMethod("simulateTransaction", [transactionXdr]); + } + + /** + * Get the latest ledger information + * + * @returns Promise resolving to latest ledger data + */ + async getLatestLedger(): Promise> { + return this.callRpcMethod("getLatestLedger", []); + } + + /** + * Get network configuration + * + * @returns Promise resolving to network config + */ + async getNetwork(): Promise> { + return this.callRpcMethod("getNetwork", []); + } + + /** + * Get contract data (storage entries) + * + * @param contractId - Contract address + * @param key - Storage key (XDR encoded) + * @param durability - Storage durability (instance, persistent, temporary) + * @returns Promise resolving to contract data + */ + async getContractData( + contractId: string, + key: string, + durability: string, + ): Promise> { + return this.callRpcMethod("getContractData", [contractId, key, durability]); + } + + /** + * Send a transaction to the network + * + * @param transactionXdr - Transaction envelope XDR + * @returns Promise resolving to send transaction result + */ + async sendTransaction(transactionXdr: string): Promise> { + return this.callRpcMethod("sendTransaction", [transactionXdr]); + } + + /** + * Get transaction status + * + * @param hash - Transaction hash + * @returns Promise resolving to transaction status + */ + async getTransactionStatus(hash: string): Promise> { + return this.callRpcMethod("getTransactionStatus", [hash]); + } + + /** + * Generic RPC method caller with retry logic + * + * @param method - RPC method name + * @param params - Method parameters + * @param retryCount - Current retry attempt (internal use) + * @returns Promise resolving to RPC response result + */ + private async callRpcMethod( + method: string, + params: any[], + retryCount: number = 0, + ): Promise { + const request: JsonRpcRequest = { + jsonrpc: "2.0", + method, + params, + id: ++this.requestIdCounter, + }; + + try { + const response = await this.client.post("", request); + + // Check for RPC error + if (response.data.error) { + throw new Error( + `RPC Error (${response.data.error.code}): ${response.data.error.message}`, + ); + } + + // Return result + return response.data.result; + } catch (error) { + // Handle axios errors + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + + // Retry on network errors or rate limiting + if ( + !axiosError.response || + axiosError.response.status === 429 || + axiosError.response.status >= 500 + ) { + if (retryCount < this.config.maxRetries) { + // Exponential backoff + const delay = Math.pow(2, retryCount) * 1000; + await this.sleep(delay); + return this.callRpcMethod(method, params, retryCount + 1); + } + } + + throw new Error( + `RPC request failed: ${axiosError.message}${axiosError.response ? ` (Status: ${axiosError.response.status})` : ""}`, + ); + } + + // Re-throw other errors + throw error; + } + } + + /** + * Sleep utility for retry backoff + * + * @param ms - Milliseconds to sleep + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/simulation/stellar/stellar-simulator.ts b/src/simulation/stellar/stellar-simulator.ts new file mode 100644 index 0000000..6c4490d --- /dev/null +++ b/src/simulation/stellar/stellar-simulator.ts @@ -0,0 +1,382 @@ +/** + * Stellar Transaction Simulator + * + * Simulates Stellar/Soroban contract calls to preview execution behavior + * and extract execution metrics before transaction submission. + */ + +import { + Address, + rpc, + TransactionBuilder, + xdr, + Operation, + Account, +} from "@stellar/stellar-sdk"; +import { StellarRpcClient } from "./stellar-rpc-client"; +import { + StellarSimulationRequest, + StellarSimulationResult, + ExecutionMetrics, + SimulationConfig, + SorobanRpcSimulationResponse, +} from "./types"; + +/** + * Stellar Transaction Simulator + * + * Provides comprehensive transaction simulation capabilities for Stellar smart contracts, + * allowing developers to preview execution behavior and analyze resource consumption + * before submitting transactions to the network. + */ +export class StellarTransactionSimulator { + private rpcClient: StellarRpcClient; + private rpcUrl: string; + + /** + * Create a new Stellar transaction simulator + * + * @param rpcUrl - Soroban RPC endpoint URL + * @param config - Optional simulation configuration + */ + constructor(rpcUrl: string, config?: SimulationConfig) { + this.rpcUrl = rpcUrl; + this.rpcClient = new StellarRpcClient(rpcUrl, config); + } + + /** + * Simulate a contract method call + * + * This is the main entry point for transaction simulation. It constructs + * a transaction, simulates it via Soroban RPC, and returns detailed + * execution metrics. + * + * @param request - Simulation request containing contract details + * @returns Promise resolving to simulation result with execution metrics + */ + async simulateContractCall( + request: StellarSimulationRequest, + ): Promise { + const startTime = Date.now(); + + try { + // Build transaction for simulation + const transactionXdr = await this.buildSimulationTransaction(request); + + // Simulate via RPC + const rpcResponse = + await this.rpcClient.simulateTransaction(transactionXdr); + + // Parse and validate response + const simulationResult = this.parseSimulationResponse( + rpcResponse, + startTime, + ); + + return simulationResult; + } catch (error) { + // Return error result + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + metrics: this.createEmptyMetrics(), + events: [], + authEntries: [], + latestLedger: 0, + timestamp: Date.now(), + }; + } + } + + /** + * Simulate multiple contract calls in parallel + * + * @param requests - Array of simulation requests + * @returns Promise resolving to array of simulation results + */ + async simulateBatch( + requests: StellarSimulationRequest[], + ): Promise { + const simulations = requests.map((request) => + this.simulateContractCall(request), + ); + + return Promise.all(simulations); + } + + /** + * Build a transaction envelope for simulation + * + * Constructs a minimal transaction suitable for RPC simulation + * without requiring a real account or signatures. + * + * @param request - Simulation request + * @returns Promise resolving to transaction envelope XDR + */ + private async buildSimulationTransaction( + request: StellarSimulationRequest, + ): Promise { + try { + // Create a dummy source account for simulation + const sourceAccount = + request.sourceAccount || this.createDummyAccountId(); + + // Create Soroban RPC server instance + const server = new rpc.Server(this.rpcUrl, { + allowHttp: this.rpcUrl.startsWith("http://"), + }); + + // Get source account details (use a dummy account for simulation) + let account: Account; + try { + account = await server.getAccount(sourceAccount); + } catch { + // Create a minimal account object for simulation + account = new Account(sourceAccount, "0"); + } + + // Create contract instance + const contractAddress = new Address(request.contractId); + + // Build the transaction + const transaction = new TransactionBuilder(account, { + fee: "100", // Minimum fee for simulation + networkPassphrase: + request.networkPassphrase || "Test SDF Network ; September 2015", // Default to testnet + }) + .addOperation( + Operation.invokeContractFunction({ + contract: request.contractId, + function: request.method, + args: request.params, + }), + ) + .setTimeout(30) + .build(); + + // Return transaction envelope XDR + return transaction.toEnvelope().toXDR("base64"); + } catch (error) { + // If we can't build a proper transaction, create a minimal simulation envelope + // This allows simulation to proceed even with incomplete data + return this.createMinimalSimulationEnvelope(request); + } + } + + /** + * Create a minimal simulation envelope when full transaction building fails + * + * @param request - Simulation request + * @returns Base64 encoded minimal transaction envelope + */ + private createMinimalSimulationEnvelope( + request: StellarSimulationRequest, + ): string { + // Create a minimal XDR structure for simulation + // This is a fallback when we can't build a complete transaction + const minimalTx = { + contractId: request.contractId, + method: request.method, + params: request.params, + }; + + // Encode as base64 for RPC transmission + return Buffer.from(JSON.stringify(minimalTx)).toString("base64"); + } + + /** + * Parse RPC simulation response into structured result + * + * @param rpcResponse - Raw RPC response + * @param startTime - Simulation start timestamp + * @returns Parsed simulation result + */ + private parseSimulationResponse( + rpcResponse: any, + startTime: number, + ): StellarSimulationResult { + const executionTime = Date.now() - startTime; + + try { + // Extract simulation result from RPC response + const result = rpcResponse.result || rpcResponse; + + // Check if simulation succeeded + if (!result.success || result.error) { + return { + success: false, + error: result.error || "Simulation failed", + metrics: this.createEmptyMetrics(), + events: result.events || [], + authEntries: result.auth || [], + latestLedger: rpcResponse.latestLedger || 0, + timestamp: Date.now(), + }; + } + + // Extract execution metrics + const metrics = this.extractExecutionMetrics(result, executionTime); + + // Extract return value + const returnValue = + result.results?.[0]?.returnValue || + result.results?.[0]?.xdr?.returnValue; + + return { + success: true, + metrics, + returnValue, + events: result.events || [], + authEntries: result.auth || [], + transactionEnvelope: rpcResponse.transactionEnvelope, + latestLedger: rpcResponse.latestLedger || 0, + timestamp: Date.now(), + }; + } catch (error) { + return { + success: false, + error: `Failed to parse simulation response: ${error instanceof Error ? error.message : "Unknown error"}`, + metrics: this.createEmptyMetrics(), + events: [], + authEntries: [], + latestLedger: 0, + timestamp: Date.now(), + }; + } + } + + /** + * Extract execution metrics from simulation result + * + * Parses the Soroban simulation response to extract detailed + * resource consumption and execution metrics. + * + * @param result - Simulation result from RPC + * @param executionTime - Total execution time in milliseconds + * @returns Extracted execution metrics + */ + private extractExecutionMetrics( + result: any, + executionTime: number, + ): ExecutionMetrics { + const transactionData = result.transactionData || {}; + const resources = transactionData.resources || {}; + const footprint = resources.footprint || {}; + + // Estimate memory usage based on resource consumption + // Soroban doesn't directly report memory, so we estimate from footprint + const estimatedMemory = this.estimateMemoryUsage(resources, footprint); + + return { + instructions: resources.instructions || 0, + memoryBytes: estimatedMemory, + transactionSizeBytes: transactionData.transactionSizeBytes || 0, + ledgerReads: footprint.readOnly?.length || 0, + ledgerWrites: footprint.readWrite?.length || 0, + readBytes: resources.readBytes || 0, + writeBytes: resources.writeBytes || 0, + eventCount: result.events?.length || 0, + authCount: result.auth?.length || 0, + minResourceFee: parseInt(result.minResourceFee || "0", 10), + executionTime, + }; + } + + /** + * Estimate memory usage from simulation resources + * + * Since Soroban doesn't directly report memory usage, we estimate + * it based on the transaction footprint and resource consumption. + * + * @param resources - Transaction resources + * @param footprint - Ledger footprint + * @returns Estimated memory usage in bytes + */ + private estimateMemoryUsage(resources: any, footprint: any): number { + // Base memory overhead + let estimatedMemory = 1024 * 1024; // 1 MB base + + // Add memory for ledger entries (estimate 4KB per entry) + const totalEntries = + (footprint.readOnly?.length || 0) + (footprint.readWrite?.length || 0); + estimatedMemory += totalEntries * 4096; + + // Add memory for data read/written (estimate 2x the data size for processing) + const totalDataBytes = + (resources.readBytes || 0) + (resources.writeBytes || 0); + estimatedMemory += totalDataBytes * 2; + + // Add memory proportional to instruction count (rough estimate) + // Assume 10 bytes of memory per instruction on average + estimatedMemory += (resources.instructions || 0) * 10; + + return estimatedMemory; + } + + /** + * Create empty metrics structure + * + * @returns Empty execution metrics with zero values + */ + private createEmptyMetrics(): ExecutionMetrics { + return { + instructions: 0, + memoryBytes: 0, + transactionSizeBytes: 0, + ledgerReads: 0, + ledgerWrites: 0, + readBytes: 0, + writeBytes: 0, + eventCount: 0, + authCount: 0, + minResourceFee: 0, + executionTime: 0, + }; + } + + /** + * Create a dummy account ID for simulation purposes + * + * @returns Dummy Stellar account ID + */ + private createDummyAccountId(): string { + // Generate a valid-looking but dummy account ID + // This is only used for simulation, not actual transactions + return "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + } + + /** + * Convert JavaScript value to Soroban XDR ScVal + * + * @param value - Value to convert + * @returns XDR ScVal representation + */ + private toScVal(value: any): xdr.ScVal { + // Use simple conversions - in production, use stellar-sdk's native conversion utilities + if (typeof value === "string") { + return xdr.ScVal.scvSymbol(value); + } else if (typeof value === "number") { + // For simulation purposes, convert to string symbol + // In production, use proper ScVal conversion from stellar-sdk + return xdr.ScVal.scvSymbol(value.toString()); + } else if (typeof value === "bigint") { + return xdr.ScVal.scvSymbol(value.toString()); + } else if (Buffer.isBuffer(value)) { + return xdr.ScVal.scvBytes(value); + } else if (Array.isArray(value)) { + return xdr.ScVal.scvVec(value.map((v) => this.toScVal(v))); + } else if (typeof value === "object" && value !== null) { + const entries = Object.entries(value).map( + ([key, val]) => + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol(key), + val: this.toScVal(val), + }), + ); + return xdr.ScVal.scvMap(entries); + } else { + // Default: treat as symbol + return xdr.ScVal.scvSymbol(String(value)); + } + } +} diff --git a/src/simulation/stellar/types.ts b/src/simulation/stellar/types.ts new file mode 100644 index 0000000..a344593 --- /dev/null +++ b/src/simulation/stellar/types.ts @@ -0,0 +1,179 @@ +/** + * Stellar Transaction Simulation Types + * + * Type definitions for simulating Stellar/Soroban contract transactions + * and extracting execution metrics. + */ + +/** + * Soroban RPC simulation response structure + */ +export interface SorobanRpcSimulationResponse { + /** Transaction envelope XDR */ + transactionEnvelope?: string; + /** Simulation result */ + result: SorobanSimulationResult; + /** Latest ledger sequence */ + latestLedger: number; + /** Error message if simulation failed */ + error?: string; +} + +/** + * Detailed simulation result from Soroban RPC + */ +export interface SorobanSimulationResult { + /** Auth entries required */ + auth?: any[]; + /** Transaction events */ + events?: any[]; + /** Execution results */ + results?: SorobanOperationResult[]; + /** Transaction resources consumed */ + transactionData: SorobanTransactionData; + /** Resource fee in stroops */ + minResourceFee: string; + /** Whether simulation succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; +} + +/** + * Operation result from simulation + */ +export interface SorobanOperationResult { + /** Auth entries for this operation */ + auth?: any[]; + /** Execution result */ + returnValue?: string; + /** Error if operation failed */ + error?: string; +} + +/** + * Transaction resource data from Soroban + */ +export interface SorobanTransactionData { + /** Resource footprint */ + resources: SorobanResources; + /** Transaction size in bytes */ + transactionSizeBytes: number; +} + +/** + * Resource consumption details + */ +export interface SorobanResources { + /** Ledger footprint */ + footprint: { + /** Read-only ledger entries */ + readOnly: any[]; + /** Read-write ledger entries */ + readWrite: any[]; + }; + /** Instructions consumed */ + instructions: number; + /** Bytes read */ + readBytes: number; + /** Bytes written */ + writeBytes: number; +} + +/** + * Stellar transaction simulation request + */ +export interface StellarSimulationRequest { + /** Contract ID to simulate */ + contractId: string; + /** Method name to call */ + method: string; + /** Method parameters (XDR encoded) */ + params: any[]; + /** Source account address */ + sourceAccount?: string; + /** Network passphrase (e.g., "Test SDF Network ; September 2015") */ + networkPassphrase?: string; + /** RPC endpoint URL */ + rpcUrl: string; + /** Whether to include detailed metrics */ + includeMetrics?: boolean; +} + +/** + * Execution metrics from simulation + */ +export interface ExecutionMetrics { + /** CPU instructions consumed */ + instructions: number; + /** Peak memory usage in bytes (estimated) */ + memoryBytes: number; + /** Transaction size in bytes */ + transactionSizeBytes: number; + /** Number of ledger entries read */ + ledgerReads: number; + /** Number of ledger entries written */ + ledgerWrites: number; + /** Total bytes read */ + readBytes: number; + /** Total bytes written */ + writeBytes: number; + /** Number of events emitted */ + eventCount: number; + /** Number of auth entries required */ + authCount: number; + /** Minimum resource fee in stroops */ + minResourceFee: number; + /** Simulation execution time (if available) */ + executionTime?: number; +} + +/** + * Stellar transaction simulation result + */ +export interface StellarSimulationResult { + /** Whether simulation succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Execution metrics */ + metrics: ExecutionMetrics; + /** Return value (XDR encoded) */ + returnValue?: string; + /** Events emitted during execution */ + events: any[]; + /** Auth entries required */ + authEntries: any[]; + /** Transaction envelope XDR (if successful) */ + transactionEnvelope?: string; + /** Latest ledger sequence */ + latestLedger: number; + /** Simulation timestamp */ + timestamp: number; +} + +/** + * Simulation configuration options + */ +export interface SimulationConfig { + /** RPC request timeout in milliseconds */ + timeout?: number; + /** Maximum retry attempts */ + maxRetries?: number; + /** Whether to include detailed execution traces */ + includeTrace?: boolean; + /** Custom headers for RPC requests */ + headers?: Record; +} + +/** + * Contract call specification + */ +export interface ContractCallSpec { + /** Contract ID */ + contractId: string; + /** Method name */ + method: string; + /** Method parameters */ + params: any[]; +} diff --git a/src/state/snapshots/stellar/index.ts b/src/state/snapshots/stellar/index.ts new file mode 100644 index 0000000..cb9c8ab --- /dev/null +++ b/src/state/snapshots/stellar/index.ts @@ -0,0 +1,19 @@ +/** + * Soroban Contract State Snapshot Module + * + * Provides tools for capturing, exporting, and restoring Soroban contract + * state snapshots for reproducible audit and debugging purposes. + */ + +export { SorobanSnapshotExporter } from "./snapshot-exporter"; +export { SorobanSnapshotRestorer } from "./snapshot-restorer"; +export { + StorageEntry, + ContractMetadata, + ContractStateSnapshot, + SnapshotExportConfig, + SnapshotRestoreConfig, + SnapshotExportResult, + SnapshotRestoreResult, + SnapshotValidationResult, +} from "./types"; diff --git a/src/state/snapshots/stellar/snapshot-exporter.spec.ts b/src/state/snapshots/stellar/snapshot-exporter.spec.ts new file mode 100644 index 0000000..01b8e3a --- /dev/null +++ b/src/state/snapshots/stellar/snapshot-exporter.spec.ts @@ -0,0 +1,329 @@ +/** + * Soroban Contract State Snapshot Exporter Tests + */ + +import { SorobanSnapshotExporter } from "./snapshot-exporter"; +import { ContractStateSnapshot, SnapshotExportConfig } from "./types"; + +describe("SorobanSnapshotExporter", () => { + let exporter: SorobanSnapshotExporter; + const testRpcUrl = "https://soroban-testnet.stellar.org"; + const testContractId = + "CDLZVWRQK6QZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF"; + + beforeEach(() => { + exporter = new SorobanSnapshotExporter(testRpcUrl); + }); + + describe("constructor", () => { + it("should create an instance with default config", () => { + expect(exporter).toBeDefined(); + }); + + it("should create an instance with custom config", () => { + const config: Partial = { + includeStorage: false, + maxEntries: 100, + }; + const customExporter = new SorobanSnapshotExporter(testRpcUrl, config); + expect(customExporter).toBeDefined(); + }); + }); + + describe("validateSnapshot", () => { + it("should validate a correct snapshot", () => { + const validSnapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot-1", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "balance", + value: "AAAADwAAAAl0b2tlbl9iYWwAAAA=", + storageType: "persistent", + capturedAt: Date.now(), + }, + ], + }; + + const result = exporter.validateSnapshot(validSnapshot); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject snapshot with missing contract ID", () => { + const invalidSnapshot: any = { + snapshotId: "test-snapshot-2", + version: "1.0.0", + metadata: { + contractId: "", + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = exporter.validateSnapshot(invalidSnapshot); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Missing contract ID in metadata"); + }); + + it("should reject snapshot with missing network passphrase", () => { + const invalidSnapshot: any = { + snapshotId: "test-snapshot-3", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = exporter.validateSnapshot(invalidSnapshot); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Missing network passphrase in metadata"); + }); + + it("should reject snapshot with invalid ledger sequence", () => { + const invalidSnapshot: any = { + snapshotId: "test-snapshot-4", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: -1, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = exporter.validateSnapshot(invalidSnapshot); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Invalid ledger sequence in metadata"); + }); + + it("should warn about empty storage entries", () => { + const validSnapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot-5", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = exporter.validateSnapshot(validSnapshot); + expect(result.valid).toBe(true); + expect(result.warnings).toContain("Snapshot contains no storage entries"); + }); + + it("should warn about large number of entries", () => { + const largeSnapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot-6", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: Array(1001).fill({ + key: "test-key", + value: "test-value", + storageType: "persistent" as const, + capturedAt: Date.now(), + }), + }; + + const result = exporter.validateSnapshot(largeSnapshot); + expect(result.valid).toBe(true); + expect(result.warnings).toContain( + "Snapshot contains a large number of entries (>1000)", + ); + }); + + it("should reject snapshot with invalid storage type", () => { + const invalidSnapshot: any = { + snapshotId: "test-snapshot-7", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "test-key", + value: "test-value", + storageType: "invalid-type", + capturedAt: Date.now(), + }, + ], + }; + + const result = exporter.validateSnapshot(invalidSnapshot); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("invalid storage type"); + }); + }); + + describe("exportToJson", () => { + it("should export snapshot to JSON string", () => { + const snapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot-json", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const json = exporter.exportToJson(snapshot); + expect(typeof json).toBe("string"); + + const parsed = JSON.parse(json); + expect(parsed.snapshotId).toBe("test-snapshot-json"); + expect(parsed.metadata.contractId).toBe(testContractId); + }); + + it("should produce valid JSON that can be parsed", () => { + const snapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot-parse", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "counter", + value: "AAAAAwAAAAE=", + storageType: "persistent", + ttlExpiration: 100000, + capturedAt: Date.now(), + }, + ], + }; + + const json = exporter.exportToJson(snapshot); + const parsed = JSON.parse(json); + + expect(parsed.storageEntries).toHaveLength(1); + expect(parsed.storageEntries[0].key).toBe("counter"); + expect(parsed.storageEntries[0].storageType).toBe("persistent"); + }); + }); + + describe("importFromJson", () => { + it("should import snapshot from JSON string", () => { + const jsonString = JSON.stringify({ + snapshotId: "imported-snapshot", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 54321, + timestamp: Date.now(), + }, + storageEntries: [], + }); + + const snapshot = exporter.importFromJson(jsonString); + expect(snapshot.snapshotId).toBe("imported-snapshot"); + expect(snapshot.metadata.ledgerSequence).toBe(54321); + }); + + it("should throw error on invalid JSON", () => { + const invalidJson = "{ invalid json }"; + expect(() => exporter.importFromJson(invalidJson)).toThrow(); + }); + + it("should preserve all snapshot data during import", () => { + const originalSnapshot: ContractStateSnapshot = { + snapshotId: "roundtrip-test", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 99999, + timestamp: Date.now(), + sourceFilePath: "/path/to/contract.rs", + contractName: "TestContract", + }, + storageEntries: [ + { + key: "owner", + value: "AAAAAQ==", + storageType: "instance", + capturedAt: Date.now(), + }, + { + key: "balance", + value: "AAAACw==", + storageType: "persistent", + ttlExpiration: 200000, + capturedAt: Date.now(), + }, + ], + description: "Test snapshot for roundtrip", + tags: ["test", "development"], + }; + + const json = exporter.exportToJson(originalSnapshot); + const imported = exporter.importFromJson(json); + + expect(imported.snapshotId).toBe(originalSnapshot.snapshotId); + expect(imported.metadata.contractName).toBe("TestContract"); + expect(imported.storageEntries).toHaveLength(2); + expect(imported.description).toBe("Test snapshot for roundtrip"); + expect(imported.tags).toEqual(["test", "development"]); + }); + }); + + describe("exportSnapshot", () => { + it("should return error for invalid contract ID", async () => { + const invalidContractId = "invalid-id"; + + const result = await exporter.exportSnapshot(invalidContractId); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.entriesExported).toBe(0); + }, 10000); + + it("should measure export duration", async () => { + // This will fail due to network but should still measure duration + const result = await exporter.exportSnapshot(testContractId); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }, 10000); + }); +}); diff --git a/src/state/snapshots/stellar/snapshot-exporter.ts b/src/state/snapshots/stellar/snapshot-exporter.ts new file mode 100644 index 0000000..eee4c1d --- /dev/null +++ b/src/state/snapshots/stellar/snapshot-exporter.ts @@ -0,0 +1,402 @@ +/** + * Soroban Contract State Snapshot Exporter + * + * Captures and exports Soroban contract state snapshots by interacting with + * the Soroban RPC to retrieve storage entries and contract metadata. + */ + +import { Address, rpc, Keypair, xdr } from "@stellar/stellar-sdk"; +import { + ContractStateSnapshot, + ContractMetadata, + StorageEntry, + SnapshotExportConfig, + SnapshotExportResult, + SnapshotValidationResult, +} from "./types"; + +/** + * Exporter for Soroban contract state snapshots + */ +export class SorobanSnapshotExporter { + private rpcUrl: string; + private server: rpc.Server; + private defaultConfig: SnapshotExportConfig; + + constructor(rpcUrl: string, config?: Partial) { + this.rpcUrl = rpcUrl; + this.server = new rpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), + }); + this.defaultConfig = { + includeStorage: true, + includeMetadata: true, + ...config, + }; + } + + /** + * Export a complete contract state snapshot + * + * @param contractId - The contract address to snapshot + * @param config - Export configuration options + * @returns Promise resolving to export result + */ + async exportSnapshot( + contractId: string, + config?: Partial, + ): Promise { + const startTime = Date.now(); + const exportConfig = { ...this.defaultConfig, ...config }; + + try { + // Validate contract ID + const contractAddress = new Address(contractId); + + // Get current ledger info + const latestLedger = await this.server.getLatestLedger(); + + // Build metadata + const metadata: ContractMetadata = { + contractId: contractAddress.toString(), + networkPassphrase: await this.getNetworkPassphrase(), + networkUrl: this.server.serverURL.toString(), + ledgerSequence: latestLedger.sequence, + timestamp: Date.now(), + }; + + // Get storage entries + const storageEntries: StorageEntry[] = exportConfig.includeStorage + ? await this.exportStorageEntries(contractId, exportConfig) + : []; + + // Build snapshot + const snapshot: ContractStateSnapshot = { + snapshotId: this.generateSnapshotId(contractId, latestLedger.sequence), + metadata, + storageEntries, + version: "1.0.0", + }; + + const duration = Date.now() - startTime; + + return { + success: true, + snapshot, + entriesExported: storageEntries.length, + durationMs: duration, + }; + } catch (error) { + const duration = Date.now() - startTime; + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + entriesExported: 0, + durationMs: duration, + }; + } + } + + /** + * Export storage entries from a contract + * + * @param contractId - The contract address + * @param config - Export configuration + * @returns Promise resolving to storage entries array + */ + private async exportStorageEntries( + contractId: string, + config: SnapshotExportConfig, + ): Promise { + const entries: StorageEntry[] = []; + const contractAddress = new Address(contractId); + + // Define storage keys to export + // Note: In a real implementation, you would need to know the storage keys + // or iterate through them if the contract provides an enumeration method + const storageKeys = await this.discoverStorageKeys(contractId); + + for (const key of storageKeys) { + try { + // Filter by key pattern if specified + if (config.keyPattern && !config.keyPattern.test(key.toString())) { + continue; + } + + // Get storage entry for each type + const storageTypes: Array<"instance" | "persistent" | "temporary"> = + config.storageTypes || ["instance", "persistent", "temporary"]; + + for (const storageType of storageTypes) { + const entry = await this.getStorageEntry( + contractAddress, + key, + storageType, + ); + + if (entry) { + // Check max entries limit + if (config.maxEntries && entries.length >= config.maxEntries) { + break; + } + + entries.push(entry); + } + } + } catch (error) { + // Skip entries that can't be read + console.warn(`Failed to read storage entry ${key}:`, error); + } + } + + return entries; + } + + /** + * Get a specific storage entry from the contract + * + * @param contractAddress - Contract address + * @param key - Storage key + * @param storageType - Type of storage + * @returns Promise resolving to storage entry or null + */ + private async getStorageEntry( + contractAddress: Address, + key: string, + storageType: "instance" | "persistent" | "temporary", + ): Promise { + try { + // Get ledger key for the storage entry + const ledgerKey = this.buildLedgerKey(contractAddress, key, storageType); + + // Get ledger entry + const response = await this.server.getLedgerEntries(ledgerKey as any); + + if (!response.entries || response.entries.length === 0) { + return null; + } + + const ledgerEntry = response.entries[0]; + + // Parse TTL if available + let ttlExpiration: number | undefined; + if (ledgerEntry.liveUntilLedgerSeq) { + ttlExpiration = Number(ledgerEntry.liveUntilLedgerSeq); + } + + return { + key, + value: ledgerEntry.val ? ledgerEntry.val.toXDR("base64") : "", + storageType, + ttlExpiration, + capturedAt: Date.now(), + }; + } catch (error) { + // Entry doesn't exist or can't be read + return null; + } + } + + /** + * Discover storage keys for a contract + * + * Note: This is a placeholder. In practice, you would need: + * 1. Contract-specific knowledge of storage keys + * 2. Or a contract method that enumerates keys + * 3. Or to parse the contract's WASM to extract key patterns + */ + private async discoverStorageKeys(contractId: string): Promise { + // Placeholder implementation + // In a real scenario, you would need to know the storage keys or + // have the contract provide a method to enumerate them + + // For now, return an empty array - this should be customized per contract + console.warn( + "Storage key discovery is not automatically available. " + + "You need to provide storage keys specific to your contract.", + ); + + return []; + } + + /** + * Build a ledger key for a storage entry + * + * @param contractAddress - Contract address + * @param key - Storage key + * @param storageType - Type of storage + * @returns Ledger key XDR + */ + private buildLedgerKey( + contractAddress: Address, + key: string, + storageType: "instance" | "persistent" | "temporary", + ): string { + // Map storage type to Soroban durability enum + const durability = + storageType === "instance" + ? rpc.Durability.Persistent + : storageType === "persistent" + ? rpc.Durability.Persistent + : rpc.Durability.Temporary; + + // Create the ledger key using XDR + try { + const ledgerKey = this.createLedgerKeyXDR( + contractAddress, + durability, + key, + ); + return ledgerKey; + } catch (error) { + throw new Error( + `Failed to build ledger key: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Create a ledger key XDR for a storage entry + */ + private createLedgerKeyXDR( + contractAddress: Address, + durability: rpc.Durability, + key: string, + ): string { + // Create contract data ledger key + const contractData = new xdr.LedgerKeyContractData({ + contract: contractAddress.toScAddress(), + key: this.parseStorageKey(key), + durability: + durability === rpc.Durability.Temporary + ? xdr.ContractDataDurability.temporary() + : xdr.ContractDataDurability.persistent(), + }); + + const ledgerKey = xdr.LedgerKey.contractData(contractData); + return ledgerKey.toXDR("base64"); + } + + /** + * Parse storage key string into SCVal + */ + private parseStorageKey(key: string): xdr.ScVal { + try { + // Try as symbol + return xdr.ScVal.scvSymbol(key); + } catch { + // Fallback to string + return xdr.ScVal.scvString(key); + } + } + + /** + * Get network passphrase + */ + private async getNetworkPassphrase(): Promise { + try { + // Get network info from the server + const networkInfo = await this.server.getNetwork(); + return networkInfo.passphrase; + } catch (error) { + // Fallback to testnet default if unable to fetch + console.warn("Failed to fetch network passphrase, using default:", error); + return "Test SDF Network ; September 2015"; // Testnet default + } + } + + /** + * Generate a unique snapshot ID + */ + private generateSnapshotId( + contractId: string, + ledgerSequence: number, + ): string { + const timestamp = Date.now(); + return `snapshot_${contractId}_${ledgerSequence}_${timestamp}`; + } + + /** + * Validate a snapshot before export or restoration + * + * @param snapshot - Snapshot to validate + * @returns Validation result + */ + validateSnapshot(snapshot: ContractStateSnapshot): SnapshotValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate metadata + if (!snapshot.metadata.contractId) { + errors.push("Missing contract ID in metadata"); + } + + if (!snapshot.metadata.networkPassphrase) { + errors.push("Missing network passphrase in metadata"); + } + + if ( + !snapshot.metadata.ledgerSequence || + snapshot.metadata.ledgerSequence < 0 + ) { + errors.push("Invalid ledger sequence in metadata"); + } + + // Validate storage entries + if (!snapshot.storageEntries || !Array.isArray(snapshot.storageEntries)) { + errors.push("Storage entries must be an array"); + } else { + snapshot.storageEntries.forEach((entry, index) => { + if (!entry.key) { + errors.push(`Storage entry ${index} missing key`); + } + if (!entry.value && entry.value !== "") { + errors.push(`Storage entry ${index} missing value`); + } + if ( + !entry.storageType || + !["instance", "persistent", "temporary"].includes(entry.storageType) + ) { + errors.push(`Storage entry ${index} has invalid storage type`); + } + }); + } + + // Warnings + if (snapshot.storageEntries.length === 0) { + warnings.push("Snapshot contains no storage entries"); + } + + if (snapshot.storageEntries.length > 1000) { + warnings.push("Snapshot contains a large number of entries (>1000)"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + snapshot, + }; + } + + /** + * Export snapshot to JSON string + * + * @param snapshot - Snapshot to export + * @returns JSON string + */ + exportToJson(snapshot: ContractStateSnapshot): string { + return JSON.stringify(snapshot, null, 2); + } + + /** + * Import snapshot from JSON string + * + * @param json - JSON string + * @returns Parsed snapshot + */ + importFromJson(json: string): ContractStateSnapshot { + return JSON.parse(json); + } +} diff --git a/src/state/snapshots/stellar/snapshot-restorer.spec.ts b/src/state/snapshots/stellar/snapshot-restorer.spec.ts new file mode 100644 index 0000000..33d8c8e --- /dev/null +++ b/src/state/snapshots/stellar/snapshot-restorer.spec.ts @@ -0,0 +1,257 @@ +/** + * Soroban Contract State Snapshot Restorer Tests + */ + +import { SorobanSnapshotRestorer } from "./snapshot-restorer"; +import { ContractStateSnapshot, SnapshotRestoreConfig } from "./types"; +import { Keypair } from "@stellar/stellar-sdk"; + +describe("SorobanSnapshotRestorer", () => { + let restorer: SorobanSnapshotRestorer; + const testRpcUrl = "https://soroban-testnet.stellar.org"; + const testContractId = + "CDLZVWRQK6QZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF5VQKQZL4QF"; + let testKeypair: Keypair; + + beforeEach(() => { + restorer = new SorobanSnapshotRestorer(testRpcUrl); + testKeypair = Keypair.random(); + }); + + describe("constructor", () => { + it("should create an instance with default config", () => { + expect(restorer).toBeDefined(); + }); + + it("should create an instance with custom config", () => { + const config: Partial = { + overwriteExisting: true, + dryRun: true, + }; + const customRestorer = new SorobanSnapshotRestorer(testRpcUrl, config); + expect(customRestorer).toBeDefined(); + }); + }); + + describe("restoreSnapshot", () => { + it("should fail validation for invalid snapshot", async () => { + const invalidSnapshot: any = { + snapshotId: "invalid-snapshot", + version: "1.0.0", + metadata: { + contractId: "", + networkPassphrase: "", + networkUrl: testRpcUrl, + ledgerSequence: -1, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = await restorer.restoreSnapshot( + invalidSnapshot, + testKeypair, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("validation failed"); + expect(result.entriesRestored).toBe(0); + }, 10000); + + it("should measure restoration duration", async () => { + const validSnapshot: ContractStateSnapshot = { + snapshotId: "test-snapshot", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = await restorer.restoreSnapshot(validSnapshot, testKeypair); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }, 15000); + + it("should return failure for network mismatch", async () => { + const wrongNetworkSnapshot: ContractStateSnapshot = { + snapshotId: "wrong-network", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Wrong Network Passphrase", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const result = await restorer.restoreSnapshot( + wrongNetworkSnapshot, + testKeypair, + ); + + // This will fail either due to network mismatch or connection error + expect(result.success).toBe(false); + }, 15000); + }); + + describe("previewRestore", () => { + it("should preview restoration without applying changes", async () => { + const snapshot: ContractStateSnapshot = { + snapshotId: "preview-test", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "counter", + value: "AAAAAwAAAAE=", + storageType: "persistent", + capturedAt: Date.now(), + }, + ], + }; + + const preview = await restorer.previewRestore(snapshot); + + expect(preview.totalEntries).toBe(1); + expect(typeof preview.existingEntries).toBe("number"); + expect(typeof preview.newEntries).toBe("number"); + expect(Array.isArray(preview.entriesToRestore)).toBe(true); + }, 15000); + + it("should handle empty snapshot", async () => { + const emptySnapshot: ContractStateSnapshot = { + snapshotId: "empty-preview", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + const preview = await restorer.previewRestore(emptySnapshot); + + expect(preview.totalEntries).toBe(0); + expect(preview.existingEntries).toBe(0); + expect(preview.newEntries).toBe(0); + expect(preview.entriesToRestore).toHaveLength(0); + }, 15000); + + it("should correctly categorize entries", async () => { + const snapshot: ContractStateSnapshot = { + snapshotId: "categorize-test", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "key1", + value: "value1", + storageType: "persistent", + capturedAt: Date.now(), + }, + { + key: "key2", + value: "value2", + storageType: "temporary", + capturedAt: Date.now(), + }, + ], + }; + + const preview = await restorer.previewRestore(snapshot); + + expect(preview.totalEntries).toBe(2); + // Both entries will likely be new since we're using a random contract + expect(preview.existingEntries + preview.newEntries).toBe(2); + }, 15000); + }); + + describe("configuration options", () => { + it("should respect dryRun config", async () => { + const dryRunRestorer = new SorobanSnapshotRestorer(testRpcUrl, { + dryRun: true, + validateBeforeRestore: false, + }); + + const snapshot: ContractStateSnapshot = { + snapshotId: "dryrun-test", + version: "1.0.0", + metadata: { + contractId: testContractId, + networkPassphrase: "Test SDF Network ; September 2015", + networkUrl: testRpcUrl, + ledgerSequence: 12345, + timestamp: Date.now(), + }, + storageEntries: [ + { + key: "test-key", + value: "test-value", + storageType: "persistent", + capturedAt: Date.now(), + }, + ], + }; + + // Dry run should not fail due to network issues + // but will fail on validation or network check + const result = await dryRunRestorer.restoreSnapshot( + snapshot, + testKeypair, + { validateBeforeRestore: false }, + ); + + // Should attempt but may fail on network checks + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }, 15000); + + it("should skip validation when configured", async () => { + const noValidationRestorer = new SorobanSnapshotRestorer(testRpcUrl, { + validateBeforeRestore: false, + }); + + const invalidSnapshot: any = { + snapshotId: "no-validation", + version: "1.0.0", + metadata: { + contractId: "", + networkPassphrase: "", + networkUrl: testRpcUrl, + ledgerSequence: -1, + timestamp: Date.now(), + }, + storageEntries: [], + }; + + // Should skip validation but fail on network check + const result = await noValidationRestorer.restoreSnapshot( + invalidSnapshot, + testKeypair, + ); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }, 15000); + }); +}); diff --git a/src/state/snapshots/stellar/snapshot-restorer.ts b/src/state/snapshots/stellar/snapshot-restorer.ts new file mode 100644 index 0000000..dd9e3f1 --- /dev/null +++ b/src/state/snapshots/stellar/snapshot-restorer.ts @@ -0,0 +1,335 @@ +/** + * Soroban Contract State Snapshot Restorer + * + * Restores Soroban contract state from snapshots by writing storage entries + * back to the contract via Soroban RPC transactions. + */ + +import { + Address, + rpc, + TransactionBuilder, + Keypair, + xdr, +} from "@stellar/stellar-sdk"; +import { + ContractStateSnapshot, + SnapshotRestoreConfig, + SnapshotRestoreResult, + StorageEntry, +} from "./types"; +import { SorobanSnapshotExporter } from "./snapshot-exporter"; + +/** + * Restorer for Soroban contract state snapshots + */ +export class SorobanSnapshotRestorer { + private rpcUrl: string; + private server: rpc.Server; + private defaultConfig: SnapshotRestoreConfig; + + constructor(rpcUrl: string, config?: Partial) { + this.rpcUrl = rpcUrl; + this.server = new rpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), + }); + this.defaultConfig = { + overwriteExisting: false, + skipExisting: true, + validateBeforeRestore: true, + dryRun: false, + ...config, + }; + } + + /** + * Restore a contract state from a snapshot + * + * @param snapshot - The snapshot to restore + * @param signerAccount - The account keypair to sign transactions + * @param config - Restoration configuration options + * @returns Promise resolving to restoration result + */ + async restoreSnapshot( + snapshot: ContractStateSnapshot, + signerAccount: Keypair, + config?: Partial, + ): Promise { + const startTime = Date.now(); + const restoreConfig = { ...this.defaultConfig, ...config }; + + try { + // Validate snapshot if configured + if (restoreConfig.validateBeforeRestore) { + const exporter = new SorobanSnapshotExporter(this.rpcUrl); + const validation = exporter.validateSnapshot(snapshot); + + if (!validation.valid) { + return { + success: false, + entriesRestored: 0, + entriesSkipped: 0, + entriesFailed: 0, + error: `Snapshot validation failed: ${validation.errors.join(", ")}`, + durationMs: Date.now() - startTime, + }; + } + } + + // Verify network match + const networkInfo = await this.server.getNetwork(); + if (snapshot.metadata.networkPassphrase !== networkInfo.passphrase) { + return { + success: false, + entriesRestored: 0, + entriesSkipped: 0, + entriesFailed: 0, + error: "Snapshot network passphrase does not match current network", + durationMs: Date.now() - startTime, + }; + } + + // Restore storage entries + let entriesRestored = 0; + let entriesSkipped = 0; + let entriesFailed = 0; + const failedEntries: Array<{ key: string; error: string }> = []; + + for (const entry of snapshot.storageEntries) { + try { + // Check if entry already exists + const exists = await this.checkStorageEntryExists( + snapshot.metadata.contractId, + entry, + ); + + if ( + exists && + restoreConfig.skipExisting && + !restoreConfig.overwriteExisting + ) { + entriesSkipped++; + continue; + } + + // Skip if dry run + if (restoreConfig.dryRun) { + entriesRestored++; + continue; + } + + // Restore the entry + await this.restoreStorageEntry( + snapshot.metadata.contractId, + entry, + signerAccount, + ); + + entriesRestored++; + } catch (error) { + entriesFailed++; + failedEntries.push({ + key: entry.key, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { + success: entriesFailed === 0, + entriesRestored, + entriesSkipped, + entriesFailed, + failedEntries: failedEntries.length > 0 ? failedEntries : undefined, + durationMs: Date.now() - startTime, + }; + } catch (error) { + return { + success: false, + entriesRestored: 0, + entriesSkipped: 0, + entriesFailed: 0, + error: + error instanceof Error ? error.message : "Unknown error occurred", + durationMs: Date.now() - startTime, + }; + } + } + + /** + * Check if a storage entry already exists + * + * @param contractId - Contract address + * @param entry - Storage entry to check + * @returns Promise resolving to boolean + */ + private async checkStorageEntryExists( + contractId: string, + entry: StorageEntry, + ): Promise { + try { + const contractAddress = new Address(contractId); + const durability = this.mapStorageType(entry.storageType); + + const ledgerKey = this.createLedgerKey( + contractAddress, + durability, + entry.key, + ); + + const response = await this.server.getLedgerEntries(ledgerKey); + return response.entries && response.entries.length > 0; + } catch (error) { + // Entry doesn't exist + return false; + } + } + + /** + * Create a ledger key for storage access + */ + private createLedgerKey( + contractAddress: Address, + durability: rpc.Durability, + key: string, + ): any { + const contractData = new xdr.LedgerKeyContractData({ + contract: contractAddress.toScAddress(), + key: this.parseStorageKey(key), + durability: + durability === rpc.Durability.Temporary + ? xdr.ContractDataDurability.temporary() + : xdr.ContractDataDurability.persistent(), + }); + + return xdr.LedgerKey.contractData(contractData); + } + + /** + * Parse storage key string into SCVal + */ + private parseStorageKey(key: string): any { + try { + return xdr.ScVal.scvSymbol(key); + } catch { + return xdr.ScVal.scvString(key); + } + } + + /** + * Restore a single storage entry + * + * @param contractId - Contract address + * @param entry - Storage entry to restore + * @param signerAccount - Account to sign the transaction + */ + private async restoreStorageEntry( + contractId: string, + entry: StorageEntry, + signerAccount: Keypair, + ): Promise { + const contractAddress = new Address(contractId); + const sourceAccount = await this.server.getAccount( + signerAccount.publicKey(), + ); + + // Build transaction to set storage + // Note: This is a simplified implementation. In practice, you would need to: + // 1. Call a specific contract method that sets the storage + // 2. Or use a privileged admin function + // 3. The exact approach depends on the contract's implementation + + const transaction = new TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase: (await this.server.getNetwork()).passphrase, + }) + .addOperation( + // This would typically be a contract invocation that sets storage + // The exact operation depends on the contract's API + // For now, this is a placeholder + {} as any, // Replace with actual operation + ) + .setTimeout(30) + .build(); + + // Sign and submit transaction + transaction.sign(signerAccount); + + try { + const response = await this.server.sendTransaction(transaction); + + if ( + response.status === "PENDING" || + response.status === "DUPLICATE" || + response.status === "TRY_AGAIN_LATER" + ) { + // Transaction submitted successfully + return; + } else { + throw new Error(`Transaction failed with status: ${response.status}`); + } + } catch (error) { + throw new Error( + `Failed to restore storage entry: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Map storage type string to Soroban RPC durability enum + * + * @param storageType - Storage type string + * @returns Soroban RPC durability type + */ + private mapStorageType( + storageType: "instance" | "persistent" | "temporary", + ): rpc.Durability { + switch (storageType) { + case "instance": + case "persistent": + return rpc.Durability.Persistent; + case "temporary": + return rpc.Durability.Temporary; + default: + throw new Error(`Invalid storage type: ${storageType}`); + } + } + + /** + * Preview restoration without applying changes + * + * @param snapshot - Snapshot to preview + * @returns Promise resolving to preview result + */ + async previewRestore(snapshot: ContractStateSnapshot): Promise<{ + totalEntries: number; + existingEntries: number; + newEntries: number; + entriesToRestore: StorageEntry[]; + }> { + let existingEntries = 0; + let newEntries = 0; + const entriesToRestore: StorageEntry[] = []; + + for (const entry of snapshot.storageEntries) { + const exists = await this.checkStorageEntryExists( + snapshot.metadata.contractId, + entry, + ); + + if (exists) { + existingEntries++; + } else { + newEntries++; + entriesToRestore.push(entry); + } + } + + return { + totalEntries: snapshot.storageEntries.length, + existingEntries, + newEntries, + entriesToRestore, + }; + } +} diff --git a/src/state/snapshots/stellar/types.ts b/src/state/snapshots/stellar/types.ts new file mode 100644 index 0000000..181bb45 --- /dev/null +++ b/src/state/snapshots/stellar/types.ts @@ -0,0 +1,143 @@ +/** + * Soroban Contract State Snapshot Types + * + * Type definitions for capturing and managing Soroban contract state snapshots + * for reproducible audit and debugging purposes. + */ + +/** + * Represents a single storage entry in a Soroban contract + */ +export interface StorageEntry { + /** Storage key (can be a symbol, address, or custom key) */ + key: string; + /** Storage value (serialized) */ + value: string; + /** Storage type (instance, persistent, temporary) */ + storageType: "instance" | "persistent" | "temporary"; + /** TTL expiration ledger sequence (if applicable) */ + ttlExpiration?: number; + /** Timestamp when this entry was captured */ + capturedAt: number; +} + +/** + * Represents contract metadata in a snapshot + */ +export interface ContractMetadata { + /** Contract ID/address */ + contractId: string; + /** Network passphrase (e.g., 'Test SDF Network ; September 2015') */ + networkPassphrase: string; + /** Network URL */ + networkUrl: string; + /** Ledger sequence when snapshot was taken */ + ledgerSequence: number; + /** Timestamp when snapshot was taken */ + timestamp: number; + /** Contract source file path (if available) */ + sourceFilePath?: string; + /** Contract name (if available) */ + contractName?: string; +} + +/** + * Represents a complete contract state snapshot + */ +export interface ContractStateSnapshot { + /** Unique snapshot identifier */ + snapshotId: string; + /** Contract metadata */ + metadata: ContractMetadata; + /** Storage entries */ + storageEntries: StorageEntry[]; + /** Snapshot version for future compatibility */ + version: string; + /** Optional description or notes */ + description?: string; + /** Tags for organizing snapshots */ + tags?: string[]; +} + +/** + * Configuration for snapshot export + */ +export interface SnapshotExportConfig { + /** Include storage entries */ + includeStorage: boolean; + /** Include metadata */ + includeMetadata: boolean; + /** Filter storage by type */ + storageTypes?: Array<"instance" | "persistent" | "temporary">; + /** Filter storage by key pattern */ + keyPattern?: RegExp; + /** Max number of entries to export (for large contracts) */ + maxEntries?: number; +} + +/** + * Configuration for snapshot restoration + */ +export interface SnapshotRestoreConfig { + /** Overwrite existing storage entries */ + overwriteExisting: boolean; + /** Skip entries that already exist */ + skipExisting: boolean; + /** Validate snapshot before restoration */ + validateBeforeRestore: boolean; + /** Dry run (preview changes without applying) */ + dryRun: boolean; +} + +/** + * Result of a snapshot export operation + */ +export interface SnapshotExportResult { + /** Success status */ + success: boolean; + /** Exported snapshot (if successful) */ + snapshot?: ContractStateSnapshot; + /** Error message (if failed) */ + error?: string; + /** Number of entries exported */ + entriesExported: number; + /** Duration in milliseconds */ + durationMs: number; +} + +/** + * Result of a snapshot restoration operation + */ +export interface SnapshotRestoreResult { + /** Success status */ + success: boolean; + /** Number of entries restored */ + entriesRestored: number; + /** Number of entries skipped */ + entriesSkipped: number; + /** Number of entries that failed to restore */ + entriesFailed: number; + /** Error message (if failed) */ + error?: string; + /** Duration in milliseconds */ + durationMs: number; + /** Details of failed entries (if any) */ + failedEntries?: Array<{ + key: string; + error: string; + }>; +} + +/** + * Snapshot validation result + */ +export interface SnapshotValidationResult { + /** Is the snapshot valid */ + valid: boolean; + /** Validation errors */ + errors: string[]; + /** Validation warnings */ + warnings: string[]; + /** Snapshot being validated */ + snapshot: ContractStateSnapshot; +}