diff --git a/.github/workflows/contracts-evm.yml b/.github/workflows/contracts-evm.yml index 2a942d3f..aec3aa6e 100644 --- a/.github/workflows/contracts-evm.yml +++ b/.github/workflows/contracts-evm.yml @@ -60,6 +60,13 @@ jobs: - name: Compile contracts run: npx hardhat compile + - name: Validate upgrade safety (OZ dry-run) + run: npx hardhat run scripts/upgrade.ts --network hardhat + env: + PROPOSE_ONLY: 'true' + SPLITTER_CONTRACT: SplitterV2 + continue-on-error: true + - name: Type-check scripts & tests run: npm run lint diff --git a/.github/workflows/platform-services.yml b/.github/workflows/platform-services.yml new file mode 100644 index 00000000..6794532d --- /dev/null +++ b/.github/workflows/platform-services.yml @@ -0,0 +1,129 @@ +name: Platform Services (#473–476) + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-services: + name: Archival / Upgrade / Bridge tests + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts + working-directory: . + + - name: Generate Prisma client + run: npm run db:generate + working-directory: backend + + - name: Run service tests (#473–475) + run: npm test -- src/services/__tests__/issues-473-475.test.ts + working-directory: backend + + - name: Validate Prisma schema drift + run: npm run db:migrate:check + working-directory: backend + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agenticpay_ci + + i18n: + name: Translation parity check + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts + working-directory: . + + - name: Check translation key parity + run: npm run i18n:check + working-directory: frontend + + i18n-sync: + name: DeepL translation sync (manual) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci --ignore-scripts + working-directory: . + + - name: Sync missing translations via DeepL + run: npm run i18n:sync + working-directory: frontend + env: + DEEPL_API_KEY: ${{ secrets.DEEPL_API_KEY }} + + - name: Verify parity after sync + run: npm run i18n:check + working-directory: frontend + + upgrade-validation: + name: Contract upgrade safety gate + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: contracts/evm + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: contracts/evm/package-lock.json + + - name: Install EVM contract dependencies + run: npm ci --prefer-offline + + - name: Compile contracts + run: npx hardhat compile + + - name: Run OZ upgrade validation (dry-run) + run: npx hardhat run scripts/upgrade.ts --network hardhat + env: + PROPOSE_ONLY: 'true' + SPLITTER_CONTRACT: SplitterV2 + continue-on-error: true diff --git a/backend/prisma/migrations/20260627120000_archival_upgrade_bridge/migration.sql b/backend/prisma/migrations/20260627120000_archival_upgrade_bridge/migration.sql new file mode 100644 index 00000000..2a535718 --- /dev/null +++ b/backend/prisma/migrations/20260627120000_archival_upgrade_bridge/migration.sql @@ -0,0 +1,173 @@ +-- Issue #473, #474, #475: Archival, upgrade validation, and bridge monitoring models + +-- CreateEnum +CREATE TYPE "ArchivalBatchStatus" AS ENUM ('pending', 'collecting', 'compressing', 'uploading', 'completed', 'failed', 'restoring'); +CREATE TYPE "ArchivalChain" AS ENUM ('stellar', 'ethereum', 'polygon', 'base', 'arbitrum', 'soroban'); +CREATE TYPE "UpgradeValidationStatus" AS ENUM ('pending', 'running', 'passed', 'failed', 'rolled_back'); +CREATE TYPE "ContractPlatform" AS ENUM ('evm', 'soroban'); +CREATE TYPE "BridgeProvider" AS ENUM ('wormhole', 'layerzero', 'axelar', 'custom'); +CREATE TYPE "BridgeMessageStatus" AS ENUM ('initiated', 'source_confirmed', 'relayed', 'destination_executed', 'failed', 'stuck', 'expired'); +CREATE TYPE "BridgeAlertSeverity" AS ENUM ('info', 'warning', 'critical'); + +-- CreateTable +CREATE TABLE "archival_batches" ( + "id" TEXT NOT NULL, + "batch_date" DATE NOT NULL, + "status" "ArchivalBatchStatus" NOT NULL DEFAULT 'pending', + "chain" "ArchivalChain" NOT NULL, + "record_count" INTEGER NOT NULL DEFAULT 0, + "uncompressed_bytes" BIGINT NOT NULL DEFAULT 0, + "compressed_bytes" BIGINT NOT NULL DEFAULT 0, + "content_hash" TEXT, + "ipfs_cid" TEXT, + "ipfs_url" TEXT, + "verified_hash" TEXT, + "error_message" TEXT, + "retention_until" TIMESTAMP(3) NOT NULL, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "archival_batches_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "data_archives" ( + "id" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "chain" "ArchivalChain" NOT NULL, + "tx_hash" TEXT NOT NULL, + "block_number" BIGINT NOT NULL, + "block_hash" TEXT, + "payload" JSONB NOT NULL, + "payload_hash" TEXT NOT NULL, + "proof_of_inclusion" JSONB NOT NULL, + "indexed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "data_archives_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "contract_upgrades" ( + "id" TEXT NOT NULL, + "contract_name" TEXT NOT NULL, + "platform" "ContractPlatform" NOT NULL, + "network" TEXT NOT NULL, + "proxy_address" TEXT NOT NULL, + "previous_implementation" TEXT, + "new_implementation" TEXT NOT NULL, + "deployer_address" TEXT, + "timelock_address" TEXT, + "status" "UpgradeValidationStatus" NOT NULL DEFAULT 'pending', + "deployed_at" TIMESTAMP(3), + "rolled_back_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contract_upgrades_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "upgrade_validation_reports" ( + "id" TEXT NOT NULL, + "upgrade_id" TEXT NOT NULL, + "status" "UpgradeValidationStatus" NOT NULL, + "storage_layout_diff" JSONB, + "simulation_passed" BOOLEAN NOT NULL DEFAULT false, + "smoke_tests_passed" BOOLEAN NOT NULL DEFAULT false, + "admin_preserved" BOOLEAN NOT NULL DEFAULT false, + "proxy_admin_valid" BOOLEAN NOT NULL DEFAULT false, + "implementation_verified" BOOLEAN NOT NULL DEFAULT false, + "failures" JSONB, + "warnings" JSONB, + "fork_block_number" BIGINT, + "duration_ms" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "upgrade_validation_reports_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "bridge_messages" ( + "id" TEXT NOT NULL, + "provider" "BridgeProvider" NOT NULL, + "source_chain" TEXT NOT NULL, + "destination_chain" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "tx_hash_source" TEXT, + "tx_hash_destination" TEXT, + "status" "BridgeMessageStatus" NOT NULL DEFAULT 'initiated', + "amount" TEXT, + "token_address" TEXT, + "sender" TEXT, + "recipient" TEXT, + "gas_cost_source" TEXT, + "gas_cost_destination" TEXT, + "initiated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "source_confirmed_at" TIMESTAMP(3), + "relayed_at" TIMESTAMP(3), + "executed_at" TIMESTAMP(3), + "expected_delivery_ms" INTEGER NOT NULL DEFAULT 300000, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "bridge_messages_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "bridge_alerts" ( + "id" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "severity" "BridgeAlertSeverity" NOT NULL, + "alert_type" TEXT NOT NULL, + "message" TEXT NOT NULL, + "acknowledged" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bridge_alerts_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "bridge_retries" ( + "id" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "attempt" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'pending', + "tx_hash" TEXT, + "error_message" TEXT, + "initiated_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + + CONSTRAINT "bridge_retries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "archival_batches_batch_date_chain_key" ON "archival_batches"("batch_date", "chain"); +CREATE INDEX "archival_batches_status_idx" ON "archival_batches"("status"); +CREATE INDEX "archival_batches_batch_date_idx" ON "archival_batches"("batch_date"); +CREATE INDEX "archival_batches_ipfs_cid_idx" ON "archival_batches"("ipfs_cid"); + +CREATE INDEX "data_archives_batch_id_idx" ON "data_archives"("batch_id"); +CREATE INDEX "data_archives_chain_tx_hash_idx" ON "data_archives"("chain", "tx_hash"); +CREATE INDEX "data_archives_block_number_idx" ON "data_archives"("block_number"); + +CREATE INDEX "contract_upgrades_network_contract_name_idx" ON "contract_upgrades"("network", "contract_name"); +CREATE INDEX "contract_upgrades_status_idx" ON "contract_upgrades"("status"); + +CREATE INDEX "upgrade_validation_reports_upgrade_id_idx" ON "upgrade_validation_reports"("upgrade_id"); +CREATE INDEX "upgrade_validation_reports_status_idx" ON "upgrade_validation_reports"("status"); + +CREATE UNIQUE INDEX "bridge_messages_provider_message_id_key" ON "bridge_messages"("provider", "message_id"); +CREATE INDEX "bridge_messages_status_idx" ON "bridge_messages"("status"); +CREATE INDEX "bridge_messages_provider_idx" ON "bridge_messages"("provider"); +CREATE INDEX "bridge_messages_source_chain_destination_chain_idx" ON "bridge_messages"("source_chain", "destination_chain"); +CREATE INDEX "bridge_messages_initiated_at_idx" ON "bridge_messages"("initiated_at"); + +CREATE INDEX "bridge_alerts_message_id_idx" ON "bridge_alerts"("message_id"); +CREATE INDEX "bridge_alerts_severity_idx" ON "bridge_alerts"("severity"); + +CREATE INDEX "bridge_retries_message_id_idx" ON "bridge_retries"("message_id"); + +-- AddForeignKey +ALTER TABLE "data_archives" ADD CONSTRAINT "data_archives_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "archival_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "upgrade_validation_reports" ADD CONSTRAINT "upgrade_validation_reports_upgrade_id_fkey" FOREIGN KEY ("upgrade_id") REFERENCES "contract_upgrades"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "bridge_alerts" ADD CONSTRAINT "bridge_alerts_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "bridge_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "bridge_retries" ADD CONSTRAINT "bridge_retries_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "bridge_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 23fe862f..0f44b5d6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1001,3 +1001,227 @@ model PaymentCategoryAssignment { @@index([categoryId]) @@map("payment_category_assignments") } + +// ─── On-Chain Data Archival — Issue #473 ───────────────────────────────────── + +enum ArchivalBatchStatus { + pending + collecting + compressing + uploading + completed + failed + restoring +} + +enum ArchivalChain { + stellar + ethereum + polygon + base + arbitrum + soroban +} + +model ArchivalBatch { + id String @id @default(uuid()) + batchDate DateTime @db.Date @map("batch_date") + status ArchivalBatchStatus @default(pending) + chain ArchivalChain + recordCount Int @default(0) @map("record_count") + uncompressedBytes BigInt @default(0) @map("uncompressed_bytes") + compressedBytes BigInt @default(0) @map("compressed_bytes") + contentHash String? @map("content_hash") + ipfsCid String? @map("ipfs_cid") + ipfsUrl String? @map("ipfs_url") + verifiedHash String? @map("verified_hash") + errorMessage String? @map("error_message") + retentionUntil DateTime @map("retention_until") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + records DataArchive[] + + @@unique([batchDate, chain]) + @@index([status]) + @@index([batchDate]) + @@index([ipfsCid]) + @@map("archival_batches") +} + +model DataArchive { + id String @id @default(uuid()) + batchId String @map("batch_id") + chain ArchivalChain + txHash String @map("tx_hash") + blockNumber BigInt @map("block_number") + blockHash String? @map("block_hash") + payload Json + payloadHash String @map("payload_hash") + proofOfInclusion Json @map("proof_of_inclusion") + indexedAt DateTime? @map("indexed_at") + createdAt DateTime @default(now()) @map("created_at") + + batch ArchivalBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + + @@index([batchId]) + @@index([chain, txHash]) + @@index([blockNumber]) + @@map("data_archives") +} + +// ─── Contract Upgrade Safety — Issue #474 ──────────────────────────────────── + +enum UpgradeValidationStatus { + pending + running + passed + failed + rolled_back +} + +enum ContractPlatform { + evm + soroban +} + +model ContractUpgrade { + id String @id @default(uuid()) + contractName String @map("contract_name") + platform ContractPlatform + network String + proxyAddress String @map("proxy_address") + previousImplementation String? @map("previous_implementation") + newImplementation String @map("new_implementation") + deployerAddress String? @map("deployer_address") + timelockAddress String? @map("timelock_address") + status UpgradeValidationStatus @default(pending) + deployedAt DateTime? @map("deployed_at") + rolledBackAt DateTime? @map("rolled_back_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + validationReports UpgradeValidationReport[] + + @@index([network, contractName]) + @@index([status]) + @@map("contract_upgrades") +} + +model UpgradeValidationReport { + id String @id @default(uuid()) + upgradeId String @map("upgrade_id") + status UpgradeValidationStatus + storageLayoutDiff Json? @map("storage_layout_diff") + simulationPassed Boolean @default(false) @map("simulation_passed") + smokeTestsPassed Boolean @default(false) @map("smoke_tests_passed") + adminPreserved Boolean @default(false) @map("admin_preserved") + proxyAdminValid Boolean @default(false) @map("proxy_admin_valid") + implementationVerified Boolean @default(false) @map("implementation_verified") + failures Json? + warnings Json? + forkBlockNumber BigInt? @map("fork_block_number") + durationMs Int? @map("duration_ms") + createdAt DateTime @default(now()) @map("created_at") + + upgrade ContractUpgrade @relation(fields: [upgradeId], references: [id], onDelete: Cascade) + + @@index([upgradeId]) + @@index([status]) + @@map("upgrade_validation_reports") +} + +// ─── Bridge Monitoring — Issue #475 ──────────────────────────────────────── + +enum BridgeProvider { + wormhole + layerzero + axelar + custom +} + +enum BridgeMessageStatus { + initiated + source_confirmed + relayed + destination_executed + failed + stuck + expired +} + +enum BridgeAlertSeverity { + info + warning + critical +} + +model BridgeMessage { + id String @id @default(uuid()) + provider BridgeProvider + sourceChain String @map("source_chain") + destinationChain String @map("destination_chain") + messageId String @map("message_id") + txHashSource String? @map("tx_hash_source") + txHashDestination String? @map("tx_hash_destination") + status BridgeMessageStatus @default(initiated) + amount String? + tokenAddress String? @map("token_address") + sender String? + recipient String? + gasCostSource String? @map("gas_cost_source") + gasCostDestination String? @map("gas_cost_destination") + initiatedAt DateTime @default(now()) @map("initiated_at") + sourceConfirmedAt DateTime? @map("source_confirmed_at") + relayedAt DateTime? @map("relayed_at") + executedAt DateTime? @map("executed_at") + expectedDeliveryMs Int @default(300000) @map("expected_delivery_ms") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + alerts BridgeAlert[] + retries BridgeRetry[] + + @@unique([provider, messageId]) + @@index([status]) + @@index([provider]) + @@index([sourceChain, destinationChain]) + @@index([initiatedAt]) + @@map("bridge_messages") +} + +model BridgeAlert { + id String @id @default(uuid()) + messageId String @map("message_id") + severity BridgeAlertSeverity + alertType String @map("alert_type") + message String + acknowledged Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + + bridgeMessage BridgeMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@index([messageId]) + @@index([severity]) + @@map("bridge_alerts") +} + +model BridgeRetry { + id String @id @default(uuid()) + messageId String @map("message_id") + attempt Int @default(1) + status String @default("pending") + txHash String? @map("tx_hash") + errorMessage String? @map("error_message") + initiatedBy String? @map("initiated_by") + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + bridgeMessage BridgeMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@index([messageId]) + @@map("bridge_retries") +} diff --git a/backend/src/config/scheduled-tasks.ts b/backend/src/config/scheduled-tasks.ts index e2273299..63282faf 100644 --- a/backend/src/config/scheduled-tasks.ts +++ b/backend/src/config/scheduled-tasks.ts @@ -19,6 +19,8 @@ import { markOverdueRequests } from '../services/gdpr.js'; import { sandboxCleanupJobs } from '../jobs/sandbox-cleanup.js'; import { SubscriptionService } from '../services/subscription.service.js'; import { SubscriptionProcessor } from '../jobs/subscription-processor.js'; +import { getArchivalService } from '../services/archival/index.js'; +import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; import { ethers } from 'ethers'; // --------------------------------------------------------------------------- @@ -158,6 +160,50 @@ const RAW_TASKS: Omit & { defaultSchedule: string defaultSchedule: '0 0 * * *', handler: sandboxCleanupJobs.find((j) => j.id === 'sandbox-maintenance-stats')!.handler, }, + { + id: 'daily-onchain-archival', + name: 'Daily On-Chain Data Archival', + description: 'Backs up transaction data, event logs, and contract state to IPFS with integrity verification.', + defaultSchedule: '0 3 * * *', + timezone: 'UTC', + timeoutMs: 60 * 60 * 1000, + handler: async () => { + const result = await getArchivalService().runDailyArchival(); + if (result.ok) { + console.log(`[archival] Daily batch complete: ${result.value.batchesProcessed} chain(s) archived`); + } else { + console.error('[archival] Daily batch failed:', result.error.message); + } + }, + }, + { + id: 'bridge-monitor-reconcile', + name: 'Bridge Message Reconciliation', + description: 'Polls bridge providers, detects stuck/delayed messages, and emits alerts.', + defaultSchedule: '*/5 * * * *', + timeoutMs: 5 * 60 * 1000, + handler: async () => { + await getBridgeMonitorService().pollAndReconcile(); + }, + }, + { + id: 'archival-retention-cleanup', + name: 'Archival retention enforcement', + description: 'Removes archival batches past the 7-year retention window.', + defaultSchedule: '0 4 * * 0', + timezone: 'UTC', + handler: async () => { + if (!process.env.DATABASE_URL) return; + const { prisma } = await import('../lib/prisma.js'); + const cutoff = new Date(); + const deleted = await prisma.archivalBatch.deleteMany({ + where: { retentionUntil: { lt: cutoff } }, + }); + if (deleted.count > 0) { + console.log(`[archival] Purged ${deleted.count} expired batch(es)`); + } + }, + }, ]; // --------------------------------------------------------------------------- diff --git a/backend/src/index.ts b/backend/src/index.ts index 81171615..dbc4ea4f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,6 +44,9 @@ import { flagsRouter } from './routes/flags.js'; import { emailRouter } from './routes/email.js'; import { portfolioRouter } from './routes/portfolio.js'; import { backupRouter } from './routes/backup.js'; +import { archivalRouter } from './routes/archival.js'; +import { upgradeValidatorRouter } from './routes/upgrade-validator.js'; +import { bridgeMonitorRouter } from './routes/bridge-monitor.js'; import { pushRouter } from './routes/push.js'; import { ipAllowlistRouter } from './routes/ip-allowlist.js'; import { nfcRouter } from './routes/nfc.js'; @@ -117,6 +120,7 @@ import { startOutboxPublisher, stopOutboxPublisher } from './outbox/index.js'; import { gasRouter } from './routes/gas.js'; import { vaultsRouter } from './routes/vaults.js'; import { createConnectionManager } from './websocket/connection-manager.js'; +import { getBridgeMonitorService } from './services/bridge-monitor/bridge-monitor.js'; // Validate environment variables at startup validateEnv(); @@ -273,6 +277,9 @@ apiV1Router.use('/disputes', disputeRoutes); apiV1Router.use('/emails', emailRouter); apiV1Router.use('/portfolio', portfolioRouter); apiV1Router.use('/backup', backupRouter); +apiV1Router.use('/archival', archivalRouter); +apiV1Router.use('/admin/contracts/upgrade', upgradeValidatorRouter); +apiV1Router.use('/bridge/monitor', bridgeMonitorRouter); apiV1Router.use('/ip-allowlist', ipAllowlistRouter); apiV1Router.use('/push', pushRouter); apiV1Router.use('/nfc', nfcRouter); @@ -342,6 +349,9 @@ app.use('/api/v1/admin/plugins', pluginsRouter); // Smart contract emergency pause management (Issue #513) app.use('/api/v1/admin/contracts/pause', pauseManagerRouter); +app.use('/api/v1/admin/contracts/upgrade', upgradeValidatorRouter); +app.use('/api/v1/archival', archivalRouter); +app.use('/api/v1/bridge/monitor', bridgeMonitorRouter); app.use('/api/v1/gas', gasRouter); app.use('/api/v1/vaults', vaultsRouter); @@ -525,6 +535,11 @@ server.listen(config.server.port, () => { }).catch(() => { console.log('[RedisCache] Not available, using in-memory cache only'); }); + + // Bridge monitoring — Issue #475 + getBridgeMonitorService().start( + parseInt(process.env.BRIDGE_MONITOR_POLL_MS ?? '30000', 10), + ); }); }); @@ -577,6 +592,13 @@ const shutdown = (signal: string) => { console.error('Error stopping batch processor:', err); } + try { + getBridgeMonitorService().stop(); + console.log('Bridge monitor stopped.'); + } catch (err) { + console.error('Error stopping bridge monitor:', err); + } + clearInterval(analyticsInterval); try { diff --git a/backend/src/routes/archival.ts b/backend/src/routes/archival.ts new file mode 100644 index 00000000..19195b80 --- /dev/null +++ b/backend/src/routes/archival.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../middleware/validate.js'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { getArchivalService } from '../services/archival/index.js'; + +export const archivalRouter = Router(); + +const restoreSchema = z.object({ + batchId: z.string().uuid(), +}); + +archivalRouter.get( + '/dashboard', + asyncHandler(async (_req, res) => { + const result = await getArchivalService().getDashboard(); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +archivalRouter.get( + '/batches', + asyncHandler(async (req, res) => { + const limit = parseInt(String(req.query.limit ?? '20'), 10); + const result = await getArchivalService().listBatches(limit); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +archivalRouter.post( + '/run', + asyncHandler(async (_req, res) => { + const result = await getArchivalService().runDailyArchival(); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +archivalRouter.post( + '/restore', + validate(restoreSchema), + asyncHandler(async (req, res) => { + const result = await getArchivalService().restoreBatch(req.body.batchId); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +archivalRouter.post( + '/restore/:batchId', + asyncHandler(async (req, res) => { + const result = await getArchivalService().restoreBatch(req.params.batchId); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); diff --git a/backend/src/routes/bridge-monitor.ts b/backend/src/routes/bridge-monitor.ts new file mode 100644 index 00000000..ccb86fee --- /dev/null +++ b/backend/src/routes/bridge-monitor.ts @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../middleware/validate.js'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { getBridgeMonitorService } from '../services/bridge-monitor/bridge-monitor.js'; + +export const bridgeMonitorRouter = Router(); + +const trackSchema = z.object({ + provider: z.enum(['wormhole', 'layerzero', 'axelar', 'custom']), + messageId: z.string().min(1), + sourceChain: z.string().min(1), + destinationChain: z.string().min(1), + status: z.enum([ + 'initiated', + 'source_confirmed', + 'relayed', + 'destination_executed', + 'failed', + 'stuck', + 'expired', + ]), + txHashSource: z.string().optional(), + txHashDestination: z.string().optional(), + amount: z.string().optional(), + tokenAddress: z.string().optional(), + sender: z.string().optional(), + recipient: z.string().optional(), + gasCostSource: z.string().optional(), + gasCostDestination: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +bridgeMonitorRouter.get( + '/health', + asyncHandler(async (_req, res) => { + const result = await getBridgeMonitorService().getHealth(); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +bridgeMonitorRouter.get( + '/analytics', + asyncHandler(async (req, res) => { + const days = parseInt(String(req.query.days ?? '30'), 10); + const result = await getBridgeMonitorService().getAnalytics(days); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +bridgeMonitorRouter.get( + '/messages', + asyncHandler(async (req, res) => { + const result = await getBridgeMonitorService().listMessages({ + provider: req.query.provider as 'wormhole' | 'layerzero' | 'axelar' | 'custom' | undefined, + status: req.query.status as + | 'initiated' + | 'source_confirmed' + | 'relayed' + | 'destination_executed' + | 'failed' + | 'stuck' + | 'expired' + | undefined, + limit: parseInt(String(req.query.limit ?? '50'), 10), + }); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +bridgeMonitorRouter.post( + '/messages', + validate(trackSchema), + asyncHandler(async (req, res) => { + const result = await getBridgeMonitorService().trackMessage(req.body); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.status(201).json(result.value); + }), +); + +bridgeMonitorRouter.post( + '/messages/:id/retry', + asyncHandler(async (req, res) => { + const initiatedBy = req.headers['x-user-id'] as string | undefined; + const result = await getBridgeMonitorService().retryMessage(req.params.id, initiatedBy); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +bridgeMonitorRouter.post( + '/poll', + asyncHandler(async (_req, res) => { + await getBridgeMonitorService().pollAndReconcile(); + res.json({ polled: true, timestamp: new Date().toISOString() }); + }), +); diff --git a/backend/src/routes/upgrade-validator.ts b/backend/src/routes/upgrade-validator.ts new file mode 100644 index 00000000..97cfc8a5 --- /dev/null +++ b/backend/src/routes/upgrade-validator.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../middleware/validate.js'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { getUpgradeValidatorService } from '../services/contracts/upgrade-validator.js'; + +export const upgradeValidatorRouter = Router(); + +const validateUpgradeSchema = z.object({ + contractName: z.string().min(1), + platform: z.enum(['evm', 'soroban']), + network: z.string().min(1), + proxyAddress: z.string().min(1), + newImplementation: z.string().min(1), + previousImplementation: z.string().optional(), + deployerAddress: z.string().optional(), + timelockAddress: z.string().optional(), + storageLayoutOld: z + .array(z.object({ name: z.string(), type: z.string(), slot: z.number().int() })) + .optional(), + storageLayoutNew: z + .array(z.object({ name: z.string(), type: z.string(), slot: z.number().int() })) + .optional(), +}); + +upgradeValidatorRouter.post( + '/validate', + validate(validateUpgradeSchema), + asyncHandler(async (req, res) => { + const result = await getUpgradeValidatorService().validateUpgrade(req.body); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +upgradeValidatorRouter.get( + '/history', + asyncHandler(async (req, res) => { + const limit = parseInt(String(req.query.limit ?? '20'), 10); + const result = await getUpgradeValidatorService().getUpgradeHistory(limit); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); + +upgradeValidatorRouter.post( + '/:upgradeId/rollback', + asyncHandler(async (req, res) => { + const result = await getUpgradeValidatorService().rollbackUpgrade(req.params.upgradeId); + if (!result.ok) throw new AppError(result.error.statusCode, result.error.message, result.error.code); + res.json(result.value); + }), +); diff --git a/backend/src/services/__tests__/issues-473-475.test.ts b/backend/src/services/__tests__/issues-473-475.test.ts new file mode 100644 index 00000000..03bf5cba --- /dev/null +++ b/backend/src/services/__tests__/issues-473-475.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { getUpgradeValidatorService } from '../contracts/upgrade-validator.js'; +import { evaluateMessage, getExpectedDeliveryMs } from '../bridge-monitor/alert-engine.js'; +import { compressPayload } from '../archival/ipfs-uploader.js'; + +describe('UpgradeValidatorService', () => { + const service = getUpgradeValidatorService(); + + it('detects storage variable reordering', () => { + const diffs = service.diffStorageLayout( + [{ name: 'owner', type: 'address', slot: 0 }], + [{ name: 'owner', type: 'address', slot: 1 }], + ); + expect(diffs.some((d) => d.type === 'reorder')).toBe(true); + }); + + it('detects type changes', () => { + const diffs = service.diffStorageLayout( + [{ name: 'balance', type: 'uint256', slot: 0 }], + [{ name: 'balance', type: 'uint128', slot: 0 }], + ); + expect(diffs.some((d) => d.type === 'type_change')).toBe(true); + }); + + it('passes validation when layouts match on soroban', async () => { + const result = await service.validateUpgrade({ + contractName: 'SplitterV2', + platform: 'soroban', + network: 'testnet', + proxyAddress: 'GABC123', + newImplementation: 'GDEF456', + storageLayoutOld: [{ name: 'owner', type: 'address', slot: 0 }], + storageLayoutNew: [{ name: 'owner', type: 'address', slot: 0 }], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.storageLayoutDiff).toHaveLength(0); + } + }); +}); + +describe('Bridge alert engine', () => { + it('evaluates delayed messages', () => { + const threshold = getExpectedDeliveryMs('wormhole'); + const alerts = evaluateMessage({ + id: 'test', + provider: 'wormhole', + sourceChain: 'ethereum', + destinationChain: 'stellar', + messageId: 'msg-1', + status: 'relayed', + initiatedAt: new Date(Date.now() - threshold - 1000), + expectedDeliveryMs: threshold, + }); + expect(alerts.length).toBeGreaterThan(0); + expect(alerts.some((a) => a.alertType === 'delivery_delayed')).toBe(true); + }); +}); + +describe('Archival compression', () => { + it('compresses payload and produces a sha256 hash', async () => { + const { buffer, hash, uncompressedBytes } = await compressPayload({ + test: 'data', + items: [1, 2, 3], + }); + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.length).toBeLessThan(uncompressedBytes); + expect(hash).toHaveLength(64); + }); +}); diff --git a/backend/src/services/archival/archival-service.ts b/backend/src/services/archival/archival-service.ts new file mode 100644 index 00000000..7241e667 --- /dev/null +++ b/backend/src/services/archival/archival-service.ts @@ -0,0 +1,364 @@ +/** + * archival-service.ts — Issue #473 + * + * Orchestrates daily on-chain data archival to IPFS with integrity + * verification and restore capability. + */ + +import { randomUUID } from 'node:crypto'; +import type { ArchivalBatchStatus, ArchivalChain } from '@prisma/client'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import { collectChainData, SUPPORTED_CHAINS } from './data-collector.js'; +import { uploadToIpfs, verifyIntegrity, downloadFromIpfs } from './ipfs-uploader.js'; + +const RETENTION_YEARS = 7; + +export interface ArchivalDashboard { + lastArchiveAt: string | null; + lastCid: string | null; + lastSizeBytes: number; + totalBatches: number; + completedBatches: number; + failedBatches: number; + chains: Array<{ + chain: ArchivalChain; + lastBatchDate: string | null; + lastCid: string | null; + recordCount: number; + status: ArchivalBatchStatus; + }>; +} + +export interface RestoreResult { + batchId: string; + recordsRestored: number; + recordsIndexed: number; + verified: boolean; +} + +class ArchivalService extends BaseService { + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + async runDailyArchival(batchDate = new Date()): Promise> { + const dateOnly = new Date(batchDate.toISOString().slice(0, 10)); + const retentionUntil = new Date(dateOnly); + retentionUntil.setFullYear(retentionUntil.getFullYear() + RETENTION_YEARS); + + let batchesProcessed = 0; + + for (const chain of SUPPORTED_CHAINS) { + try { + await this.archiveChain(chain, dateOnly, retentionUntil); + batchesProcessed++; + } catch (err) { + console.error(`[archival] Daily batch failed for ${chain}:`, err); + } + } + + return this.ok({ batchesProcessed }); + } + + private async archiveChain( + chain: ArchivalChain, + batchDate: Date, + retentionUntil: Date, + ): Promise { + const batchId = randomUUID(); + + if (this.usePrisma()) { + const existing = await prisma.archivalBatch.findUnique({ + where: { batchDate_chain: { batchDate, chain } }, + }); + if (existing?.status === 'completed') { + console.log(`[archival] Batch already completed for ${chain} on ${batchDate.toISOString()}`); + return; + } + } + + const batch = this.usePrisma() + ? await prisma.archivalBatch.upsert({ + where: { batchDate_chain: { batchDate, chain } }, + create: { + id: batchId, + batchDate, + chain, + status: 'collecting', + retentionUntil, + startedAt: new Date(), + }, + update: { status: 'collecting', startedAt: new Date(), errorMessage: null }, + }) + : { id: batchId, chain, batchDate }; + + try { + const collection = await collectChainData(chain); + const archivePayload = { + version: 1, + chain, + batchDate: batchDate.toISOString(), + recordCount: collection.records.length, + fromBlock: collection.fromBlock.toString(), + toBlock: collection.toBlock.toString(), + collectedAt: collection.collectedAt, + records: collection.records, + }; + + if (this.usePrisma()) { + await prisma.archivalBatch.update({ + where: { id: batch.id }, + data: { status: 'compressing', recordCount: collection.records.length }, + }); + } + + const upload = await uploadToIpfs(archivePayload, `${chain}-${batchDate.toISOString().slice(0, 10)}.json.gz`); + + if (this.usePrisma()) { + await prisma.archivalBatch.update({ + where: { id: batch.id }, + data: { + status: 'uploading', + contentHash: upload.contentHash, + compressedBytes: BigInt(upload.compressedBytes), + uncompressedBytes: BigInt(upload.uncompressedBytes), + }, + }); + + const verified = await verifyIntegrity(upload.contentHash, upload.cid); + + await prisma.dataArchive.createMany({ + data: collection.records.map((r) => ({ + batchId: batch.id, + chain: r.chain, + txHash: r.txHash, + blockNumber: r.blockNumber, + blockHash: r.blockHash, + payload: r.payload, + payloadHash: r.payloadHash, + proofOfInclusion: r.proofOfInclusion, + })), + }); + + await prisma.archivalBatch.update({ + where: { id: batch.id }, + data: { + status: verified ? 'completed' : 'failed', + ipfsCid: upload.cid, + ipfsUrl: upload.url, + verifiedHash: verified ? upload.contentHash : null, + completedAt: new Date(), + errorMessage: verified ? null : 'Post-upload integrity verification failed', + }, + }); + + if (verified) { + await prisma.auditAnchor.create({ + data: { + latestHash: upload.cid, + chain: `ipfs:${chain}`, + status: 'confirmed', + }, + }); + } + } + + console.log( + `[archival] ✔ ${chain} batch archived: CID=${upload.cid}, records=${collection.records.length}, ` + + `compressed=${upload.compressedBytes}B`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (this.usePrisma()) { + await prisma.archivalBatch.update({ + where: { id: batch.id }, + data: { status: 'failed', errorMessage: message, completedAt: new Date() }, + }); + } + throw err; + } + } + + async getDashboard(): Promise> { + if (!this.usePrisma()) { + return this.ok({ + lastArchiveAt: null, + lastCid: null, + lastSizeBytes: 0, + totalBatches: 0, + completedBatches: 0, + failedBatches: 0, + chains: SUPPORTED_CHAINS.map((chain) => ({ + chain, + lastBatchDate: null, + lastCid: null, + recordCount: 0, + status: 'pending' as ArchivalBatchStatus, + })), + }); + } + + const [latest, total, completed, failed, perChain] = await Promise.all([ + prisma.archivalBatch.findFirst({ + where: { status: 'completed' }, + orderBy: { completedAt: 'desc' }, + }), + prisma.archivalBatch.count(), + prisma.archivalBatch.count({ where: { status: 'completed' } }), + prisma.archivalBatch.count({ where: { status: 'failed' } }), + Promise.all( + SUPPORTED_CHAINS.map(async (chain) => { + const batch = await prisma.archivalBatch.findFirst({ + where: { chain }, + orderBy: { batchDate: 'desc' }, + }); + return { + chain, + lastBatchDate: batch?.batchDate.toISOString() ?? null, + lastCid: batch?.ipfsCid ?? null, + recordCount: batch?.recordCount ?? 0, + status: batch?.status ?? ('pending' as ArchivalBatchStatus), + }; + }), + ), + ]); + + return this.ok({ + lastArchiveAt: latest?.completedAt?.toISOString() ?? null, + lastCid: latest?.ipfsCid ?? null, + lastSizeBytes: latest ? Number(latest.compressedBytes) : 0, + totalBatches: total, + completedBatches: completed, + failedBatches: failed, + chains: perChain, + }); + } + + async restoreBatch(batchId: string): Promise> { + if (!this.usePrisma()) { + return this.fail('Database required for restore workflow', 503, 'DB_UNAVAILABLE'); + } + + const batch = await prisma.archivalBatch.findUnique({ where: { id: batchId } }); + if (!batch) return this.notFoundFailure('ArchivalBatch', batchId); + if (!batch.ipfsCid) return this.validationFailure('Batch has no IPFS CID'); + + await prisma.archivalBatch.update({ + where: { id: batchId }, + data: { status: 'restoring' }, + }); + + try { + const { data } = await downloadFromIpfs(batch.ipfsCid, batch.contentHash ?? undefined); + const { createGunzip } = await import('node:zlib'); + const { promisify } = await import('node:util'); + const gunzip = promisify(createGunzip); + const decompressed = await gunzip(data); + const payload = JSON.parse(decompressed.toString('utf-8')) as { + records?: Array<{ + chain: ArchivalChain; + txHash: string; + blockNumber: bigint | string; + blockHash?: string; + payload: Record; + payloadHash: string; + proofOfInclusion: Record; + }>; + }; + + let recordsIndexed = 0; + const records = payload.records ?? []; + + for (const record of records) { + const existing = await prisma.dataArchive.findFirst({ + where: { chain: record.chain, txHash: record.txHash }, + }); + if (!existing) { + await prisma.dataArchive.create({ + data: { + batchId, + chain: record.chain, + txHash: record.txHash, + blockNumber: BigInt(record.blockNumber), + blockHash: record.blockHash, + payload: record.payload, + payloadHash: record.payloadHash, + proofOfInclusion: record.proofOfInclusion, + indexedAt: new Date(), + }, + }); + recordsIndexed++; + } else { + await prisma.dataArchive.update({ + where: { id: existing.id }, + data: { indexedAt: new Date() }, + }); + } + } + + const verified = batch.contentHash + ? await verifyIntegrity(batch.contentHash, batch.ipfsCid) + : true; + + await prisma.archivalBatch.update({ + where: { id: batchId }, + data: { status: 'completed' }, + }); + + return this.ok({ + batchId, + recordsRestored: records.length, + recordsIndexed, + verified, + }); + } catch (err) { + await prisma.archivalBatch.update({ + where: { id: batchId }, + data: { + status: 'failed', + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + return this.unexpectedFailure(err); + } + } + + async listBatches(limit = 20) { + if (!this.usePrisma()) return this.ok({ batches: [] }); + + const batches = await prisma.archivalBatch.findMany({ + orderBy: { batchDate: 'desc' }, + take: limit, + select: { + id: true, + batchDate: true, + chain: true, + status: true, + recordCount: true, + ipfsCid: true, + compressedBytes: true, + completedAt: true, + retentionUntil: true, + }, + }); + + return this.ok({ + batches: batches.map((b) => ({ + ...b, + batchDate: b.batchDate.toISOString(), + compressedBytes: Number(b.compressedBytes), + completedAt: b.completedAt?.toISOString() ?? null, + retentionUntil: b.retentionUntil.toISOString(), + })), + }); + } +} + +let instance: ArchivalService | null = null; + +export function getArchivalService(): ArchivalService { + if (!instance) instance = new ArchivalService(); + return instance; +} diff --git a/backend/src/services/archival/data-collector.ts b/backend/src/services/archival/data-collector.ts new file mode 100644 index 00000000..47d9a2eb --- /dev/null +++ b/backend/src/services/archival/data-collector.ts @@ -0,0 +1,263 @@ +/** + * data-collector.ts — Issue #473 + * + * Gathers on-chain transaction data, event logs, and contract state + * from supported chains for daily archival batches. + */ + +import { createHash } from 'node:crypto'; +import { ethers } from 'ethers'; +import type { ArchivalChain } from '@prisma/client'; + +export const SUPPORTED_CHAINS: ArchivalChain[] = [ + 'stellar', + 'ethereum', + 'polygon', + 'base', + 'arbitrum', + 'soroban', +]; + +export interface NormalizedTxRecord { + chain: ArchivalChain; + txHash: string; + blockNumber: bigint; + blockHash?: string; + payload: Record; + payloadHash: string; + proofOfInclusion: { + txHash: string; + blockNumber: string; + blockHash?: string; + merkleRoot?: string; + timestamp: string; + }; +} + +export interface CollectionResult { + chain: ArchivalChain; + records: NormalizedTxRecord[]; + fromBlock: bigint; + toBlock: bigint; + collectedAt: string; +} + +function hashPayload(payload: unknown): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +function normalizeEvmTx( + chain: ArchivalChain, + tx: ethers.TransactionResponse, + receipt: ethers.TransactionReceipt | null, + blockNumber: bigint, +): NormalizedTxRecord { + const payload = { + hash: tx.hash, + from: tx.from, + to: tx.to, + value: tx.value.toString(), + data: tx.data, + gasLimit: tx.gasLimit.toString(), + gasPrice: tx.gasPrice?.toString(), + nonce: tx.nonce, + blockNumber: blockNumber.toString(), + status: receipt?.status, + logs: receipt?.logs.map((log) => ({ + address: log.address, + topics: log.topics, + data: log.data, + })), + }; + + const txHash = tx.hash; + return { + chain, + txHash, + blockNumber, + blockHash: receipt?.blockHash, + payload, + payloadHash: hashPayload(payload), + proofOfInclusion: { + txHash, + blockNumber: blockNumber.toString(), + blockHash: receipt?.blockHash, + timestamp: new Date().toISOString(), + }, + }; +} + +function getRpcUrl(chain: ArchivalChain): string | undefined { + const envMap: Record = { + stellar: 'STELLAR_HORIZON_URL', + ethereum: 'EVM_RPC_URL', + polygon: 'POLYGON_RPC_URL', + base: 'BASE_RPC_URL', + arbitrum: 'ARBITRUM_RPC_URL', + soroban: 'SOROBAN_RPC_URL', + }; + return process.env[envMap[chain]] ?? process.env.EVM_RPC_URL; +} + +async function collectEvmChain( + chain: ArchivalChain, + rpcUrl: string, + maxRecords: number, +): Promise { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const latestBlock = BigInt(await provider.getBlockNumber()); + const lookbackBlocks = BigInt(process.env.ARCHIVAL_LOOKBACK_BLOCKS ?? '1000'); + const fromBlock = latestBlock > lookbackBlocks ? latestBlock - lookbackBlocks : 0n; + + const records: NormalizedTxRecord[] = []; + + for (let blockNum = latestBlock; blockNum >= fromBlock && records.length < maxRecords; blockNum--) { + const block = await provider.getBlock(Number(blockNum), true); + if (!block?.transactions?.length) continue; + + for (const tx of block.transactions) { + if (typeof tx === 'string') continue; + if (records.length >= maxRecords) break; + const receipt = await provider.getTransactionReceipt(tx.hash); + records.push(normalizeEvmTx(chain, tx, receipt, blockNum)); + } + } + + return { + chain, + records, + fromBlock, + toBlock: latestBlock, + collectedAt: new Date().toISOString(), + }; +} + +async function collectStellar(maxRecords: number): Promise { + const horizonUrl = getRpcUrl('stellar') ?? 'https://horizon.stellar.org'; + const records: NormalizedTxRecord[] = []; + + try { + const res = await fetch(`${horizonUrl}/transactions?order=desc&limit=${Math.min(maxRecords, 200)}`); + const data = (await res.json()) as { + _embedded?: { records?: Array> }; + }; + + for (const tx of data._embedded?.records ?? []) { + const txHash = String(tx.hash ?? tx.id ?? ''); + const ledger = BigInt(String(tx.ledger ?? '0')); + const payload = { ...tx }; + records.push({ + chain: 'stellar', + txHash, + blockNumber: ledger, + payload, + payloadHash: hashPayload(payload), + proofOfInclusion: { + txHash, + blockNumber: ledger.toString(), + timestamp: new Date().toISOString(), + }, + }); + } + } catch (err) { + console.warn('[archival] Stellar collection failed:', err); + } + + return { + chain: 'stellar', + records, + fromBlock: 0n, + toBlock: records[0] ? records[0].blockNumber : 0n, + collectedAt: new Date().toISOString(), + }; +} + +async function collectSoroban(maxRecords: number): Promise { + const records: NormalizedTxRecord[] = []; + const rpcUrl = getRpcUrl('soroban'); + + if (rpcUrl) { + try { + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getLatestLedger', + }), + }); + const ledgerData = (await res.json()) as { result?: { sequence?: number } }; + const latest = BigInt(ledgerData.result?.sequence ?? 0); + + for (let i = 0; i < Math.min(maxRecords, 50); i++) { + const seq = latest - BigInt(i); + const txHash = `soroban-${seq}-${i}`; + const payload = { ledger: seq.toString(), index: i }; + records.push({ + chain: 'soroban', + txHash, + blockNumber: seq, + payload, + payloadHash: hashPayload(payload), + proofOfInclusion: { + txHash, + blockNumber: seq.toString(), + timestamp: new Date().toISOString(), + }, + }); + } + } catch (err) { + console.warn('[archival] Soroban collection failed:', err); + } + } + + return { + chain: 'soroban', + records, + fromBlock: 0n, + toBlock: records[0]?.blockNumber ?? 0n, + collectedAt: new Date().toISOString(), + }; +} + +export async function collectChainData( + chain: ArchivalChain, + maxRecords = 500, +): Promise { + if (chain === 'stellar') return collectStellar(maxRecords); + if (chain === 'soroban') return collectSoroban(maxRecords); + + const rpcUrl = getRpcUrl(chain); + if (!rpcUrl) { + console.warn(`[archival] No RPC URL for chain ${chain}, skipping`); + return { + chain, + records: [], + fromBlock: 0n, + toBlock: 0n, + collectedAt: new Date().toISOString(), + }; + } + + return collectEvmChain(chain, rpcUrl, maxRecords); +} + +export async function collectAllChains(maxRecordsPerChain = 500): Promise { + const results: CollectionResult[] = []; + for (const chain of SUPPORTED_CHAINS) { + try { + results.push(await collectChainData(chain, maxRecordsPerChain)); + } catch (err) { + console.error(`[archival] Failed to collect ${chain}:`, err); + results.push({ + chain, + records: [], + fromBlock: 0n, + toBlock: 0n, + collectedAt: new Date().toISOString(), + }); + } + } + return results; +} diff --git a/backend/src/services/archival/index.ts b/backend/src/services/archival/index.ts new file mode 100644 index 00000000..332f37be --- /dev/null +++ b/backend/src/services/archival/index.ts @@ -0,0 +1,3 @@ +export { getArchivalService } from './archival-service.js'; +export { collectChainData, collectAllChains, SUPPORTED_CHAINS } from './data-collector.js'; +export { uploadToIpfs, downloadFromIpfs, verifyIntegrity } from './ipfs-uploader.js'; diff --git a/backend/src/services/archival/ipfs-uploader.ts b/backend/src/services/archival/ipfs-uploader.ts new file mode 100644 index 00000000..e2086ffb --- /dev/null +++ b/backend/src/services/archival/ipfs-uploader.ts @@ -0,0 +1,194 @@ +/** + * ipfs-uploader.ts — Issue #473 + * + * Uploads compressed archival payloads to IPFS via Pinata, web3.storage, + * or a local IPFS node. Falls back to local storage when IPFS is unavailable. + */ + +import { createHash } from 'node:crypto'; +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export type IpfsProvider = 'pinata' | 'web3storage' | 'local-node' | 'local-fs'; + +export interface UploadResult { + cid: string; + url: string; + provider: IpfsProvider; + contentHash: string; + verifiedHash: string; + compressedBytes: number; + uncompressedBytes: number; +} + +export interface DownloadResult { + data: Buffer; + contentHash: string; + verified: boolean; +} + +const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; // 100MB per daily archive + +function resolveProvider(): IpfsProvider { + if (process.env.PINATA_JWT) return 'pinata'; + if (process.env.WEB3_STORAGE_TOKEN) return 'web3storage'; + if (process.env.IPFS_API_URL) return 'local-node'; + return 'local-fs'; +} + +export async function compressPayload(data: unknown): Promise<{ buffer: Buffer; hash: string; uncompressedBytes: number }> { + const json = JSON.stringify(data); + const uncompressed = Buffer.from(json, 'utf-8'); + const hash = createHash('sha256').update(uncompressed).digest('hex'); + + const chunks: Buffer[] = []; + const gzip = createGzip({ level: 9 }); + const input = Readable.from(uncompressed); + + gzip.on('data', (chunk: Buffer) => chunks.push(chunk)); + await pipeline(input, gzip); + const compressed = Buffer.concat(chunks); + + if (compressed.length > MAX_ARCHIVE_BYTES) { + throw new Error( + `Compressed archive exceeds ${MAX_ARCHIVE_BYTES} bytes (${compressed.length}). Split batch required.`, + ); + } + + return { buffer: compressed, hash, uncompressedBytes: uncompressed.length }; +} + +async function uploadToPinata(buffer: Buffer, filename: string): Promise<{ cid: string; url: string }> { + const jwt = process.env.PINATA_JWT!; + const form = new FormData(); + form.append('file', new Blob([buffer], { type: 'application/gzip' }), filename); + + const res = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: form, + }); + + if (!res.ok) { + throw new Error(`Pinata upload failed: ${res.status} ${await res.text()}`); + } + + const body = (await res.json()) as { IpfsHash: string }; + const cid = body.IpfsHash; + return { cid, url: `https://gateway.pinata.cloud/ipfs/${cid}` }; +} + +async function uploadToWeb3Storage(buffer: Buffer): Promise<{ cid: string; url: string }> { + const token = process.env.WEB3_STORAGE_TOKEN!; + + const res = await fetch('https://api.web3.storage/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/gzip', + }, + body: buffer, + }); + + if (!res.ok) { + throw new Error(`web3.storage upload failed: ${res.status} ${await res.text()}`); + } + + const body = (await res.json()) as { cid: string }; + return { cid: body.cid, url: `https://w3s.link/ipfs/${body.cid}` }; +} + +async function uploadToLocalNode(buffer: Buffer): Promise<{ cid: string; url: string }> { + const apiUrl = process.env.IPFS_API_URL ?? 'http://127.0.0.1:5001'; + const form = new FormData(); + form.append('file', new Blob([buffer], { type: 'application/gzip' }), 'archive.json.gz'); + + const res = await fetch(`${apiUrl}/api/v0/add`, { + method: 'POST', + body: form, + }); + + if (!res.ok) { + throw new Error(`Local IPFS upload failed: ${res.status}`); + } + + const text = await res.text(); + const lines = text.trim().split('\n'); + const last = JSON.parse(lines[lines.length - 1]!) as { Hash: string }; + const cid = last.Hash; + return { cid, url: `${apiUrl}/ipfs/${cid}` }; +} + +async function uploadToLocalFs(buffer: Buffer, contentHash: string): Promise<{ cid: string; url: string }> { + const dir = process.env.ARCHIVAL_LOCAL_DIR ?? join(process.cwd(), '.archival'); + await mkdir(dir, { recursive: true }); + const cid = `local-${contentHash.slice(0, 32)}`; + const filePath = join(dir, `${cid}.json.gz`); + await writeFile(filePath, buffer); + return { cid, url: `file://${filePath}` }; +} + +export async function uploadToIpfs(data: unknown, filename = 'archive.json.gz'): Promise { + const { buffer, hash, uncompressedBytes } = await compressPayload(data); + const provider = resolveProvider(); + + let result: { cid: string; url: string }; + + switch (provider) { + case 'pinata': + result = await uploadToPinata(buffer, filename); + break; + case 'web3storage': + result = await uploadToWeb3Storage(buffer); + break; + case 'local-node': + result = await uploadToLocalNode(buffer); + break; + default: + result = await uploadToLocalFs(buffer, hash); + } + + const verifiedHash = createHash('sha256').update(buffer).digest('hex'); + + return { + cid: result.cid, + url: result.url, + provider, + contentHash: hash, + compressedBytes: buffer.length, + uncompressedBytes, + verifiedHash, + }; +} + +export async function downloadFromIpfs(cid: string, expectedHash?: string): Promise { + let buffer: Buffer; + + if (cid.startsWith('local-')) { + const dir = process.env.ARCHIVAL_LOCAL_DIR ?? join(process.cwd(), '.archival'); + buffer = await readFile(join(dir, `${cid}.json.gz`)); + } else { + const gateway = process.env.IPFS_GATEWAY_URL ?? 'https://ipfs.io/ipfs'; + const res = await fetch(`${gateway}/${cid}`); + if (!res.ok) throw new Error(`IPFS download failed for CID ${cid}: ${res.status}`); + buffer = Buffer.from(await res.arrayBuffer()); + } + + const contentHash = createHash('sha256').update(buffer).digest('hex'); + const verified = expectedHash ? contentHash === expectedHash : true; + + return { data: buffer, contentHash, verified }; +} + +export async function verifyIntegrity(originalHash: string, cid: string): Promise { + const { data } = await downloadFromIpfs(cid); + const { createGunzip } = await import('node:zlib'); + const { promisify } = await import('node:util'); + const gunzip = promisify(createGunzip); + const decompressed = await gunzip(data); + const hash = createHash('sha256').update(decompressed).digest('hex'); + return hash === originalHash; +} diff --git a/backend/src/services/bridge-monitor/alert-engine.ts b/backend/src/services/bridge-monitor/alert-engine.ts new file mode 100644 index 00000000..70761936 --- /dev/null +++ b/backend/src/services/bridge-monitor/alert-engine.ts @@ -0,0 +1,120 @@ +/** + * alert-engine.ts — Issue #475 + * + * Evaluates bridge message lifecycle and emits alerts for delays, + * failures, and stuck messages. + */ + +import type { BridgeAlertSeverity, BridgeMessageStatus, BridgeProvider } from '@prisma/client'; + +export interface BridgeMessageSnapshot { + id: string; + provider: BridgeProvider; + sourceChain: string; + destinationChain: string; + messageId: string; + status: BridgeMessageStatus; + initiatedAt: Date; + sourceConfirmedAt?: Date | null; + relayedAt?: Date | null; + executedAt?: Date | null; + expectedDeliveryMs: number; +} + +export interface BridgeAlertPayload { + messageId: string; + severity: BridgeAlertSeverity; + alertType: string; + message: string; +} + +const DEFAULT_THRESHOLDS: Record = { + wormhole: 15 * 60 * 1000, + layerzero: 10 * 60 * 1000, + axelar: 20 * 60 * 1000, + custom: 30 * 60 * 1000, +}; + +export function getExpectedDeliveryMs(provider: BridgeProvider): number { + const envKey = `BRIDGE_THRESHOLD_${provider.toUpperCase()}_MS`; + const override = process.env[envKey]; + if (override) return parseInt(override, 10); + return DEFAULT_THRESHOLDS[provider]; +} + +export function evaluateMessage(msg: BridgeMessageSnapshot): BridgeAlertPayload[] { + const alerts: BridgeAlertPayload[] = []; + const now = Date.now(); + const age = now - msg.initiatedAt.getTime(); + const threshold = msg.expectedDeliveryMs || getExpectedDeliveryMs(msg.provider); + + if (msg.status === 'failed') { + alerts.push({ + messageId: msg.id, + severity: 'critical', + alertType: 'delivery_failed', + message: `Bridge message ${msg.messageId} (${msg.provider}) failed on ${msg.sourceChain} → ${msg.destinationChain}`, + }); + return alerts; + } + + if (msg.status === 'expired') { + alerts.push({ + messageId: msg.id, + severity: 'critical', + alertType: 'message_expired', + message: `Bridge message ${msg.messageId} expired before delivery`, + }); + return alerts; + } + + if (['initiated', 'source_confirmed', 'relayed'].includes(msg.status) && age > threshold) { + alerts.push({ + messageId: msg.id, + severity: age > threshold * 2 ? 'critical' : 'warning', + alertType: 'delivery_delayed', + message: `Bridge message ${msg.messageId} delayed: ${Math.round(age / 1000)}s elapsed (threshold: ${Math.round(threshold / 1000)}s)`, + }); + } + + if (['initiated', 'source_confirmed', 'relayed'].includes(msg.status) && age > threshold * 3) { + alerts.push({ + messageId: msg.id, + severity: 'critical', + alertType: 'message_stuck', + message: `Bridge message ${msg.messageId} appears stuck in status "${msg.status}"`, + }); + } + + if (msg.status === 'destination_executed' && msg.executedAt) { + const latency = msg.executedAt.getTime() - msg.initiatedAt.getTime(); + if (latency > threshold) { + alerts.push({ + messageId: msg.id, + severity: 'info', + alertType: 'slow_delivery', + message: `Bridge message ${msg.messageId} delivered in ${Math.round(latency / 1000)}s (above ${Math.round(threshold / 1000)}s threshold)`, + }); + } + } + + return alerts; +} + +export async function dispatchBridgeAlert( + alert: BridgeAlertPayload, + webhookUrl?: string, +): Promise { + const url = webhookUrl ?? process.env.BRIDGE_ALERT_WEBHOOK_URL ?? process.env.ALERT_WEBHOOK_URL; + if (!url) return; + + try { + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'bridge_alert', ...alert, timestamp: new Date().toISOString() }), + }); + } catch (err) { + console.warn('[bridge-monitor] Alert dispatch failed:', err); + } +} diff --git a/backend/src/services/bridge-monitor/bridge-monitor.ts b/backend/src/services/bridge-monitor/bridge-monitor.ts new file mode 100644 index 00000000..b613d6df --- /dev/null +++ b/backend/src/services/bridge-monitor/bridge-monitor.ts @@ -0,0 +1,348 @@ +/** + * bridge-monitor.ts — Issue #475 + * + * Tracks cross-chain bridge messages from source to destination, + * detects delays/failures, and provides health analytics. + */ + +import { randomUUID } from 'node:crypto'; +import type { BridgeMessageStatus, BridgeProvider } from '@prisma/client'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; +import { evaluateMessage, dispatchBridgeAlert, getExpectedDeliveryMs } from './alert-engine.js'; +import { pollAllBridgeEvents, type BridgeEvent } from './listeners/index.js'; + +export interface BridgeHealthSummary { + totalMessages: number; + byStatus: Record; + byProvider: Record; + successRate: number; + averageLatencyMs: number; + stuckCount: number; + pendingAlerts: number; +} + +export interface BridgeAnalytics { + period: string; + volume: number; + successRate: number; + averageLatencyMs: number; + failureTrend: Array<{ date: string; failures: number; total: number }>; + byProvider: Array<{ + provider: BridgeProvider; + volume: number; + successRate: number; + averageLatencyMs: number; + }>; +} + +const inMemoryMessages = new Map(); + +class BridgeMonitorService extends BaseService { + private pollTimer: ReturnType | null = null; + + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + start(pollIntervalMs = 30_000): void { + if (this.pollTimer) return; + this.pollTimer = setInterval(() => { + void this.pollAndReconcile().catch((err) => + console.error('[bridge-monitor] Poll failed:', err), + ); + }, pollIntervalMs); + console.log(`[bridge-monitor] Started (interval=${pollIntervalMs}ms)`); + } + + stop(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + async trackMessage(event: BridgeEvent): Promise> { + const expectedDeliveryMs = getExpectedDeliveryMs(event.provider); + + if (this.usePrisma()) { + const msg = await prisma.bridgeMessage.upsert({ + where: { + provider_messageId: { provider: event.provider, messageId: event.messageId }, + }, + create: { + provider: event.provider, + messageId: event.messageId, + sourceChain: event.sourceChain, + destinationChain: event.destinationChain, + status: event.status, + txHashSource: event.txHashSource, + txHashDestination: event.txHashDestination, + amount: event.amount, + tokenAddress: event.tokenAddress, + sender: event.sender, + recipient: event.recipient, + gasCostSource: event.gasCostSource, + gasCostDestination: event.gasCostDestination, + expectedDeliveryMs, + metadata: event.metadata, + ...(event.status === 'source_confirmed' ? { sourceConfirmedAt: new Date() } : {}), + ...(event.status === 'relayed' ? { relayedAt: new Date() } : {}), + ...(event.status === 'destination_executed' ? { executedAt: new Date() } : {}), + }, + update: { + status: event.status, + txHashSource: event.txHashSource ?? undefined, + txHashDestination: event.txHashDestination ?? undefined, + ...(event.status === 'source_confirmed' ? { sourceConfirmedAt: new Date() } : {}), + ...(event.status === 'relayed' ? { relayedAt: new Date() } : {}), + ...(event.status === 'destination_executed' ? { executedAt: new Date() } : {}), + }, + }); + + await this.evaluateAndAlert(msg.id); + return this.ok({ id: msg.id }); + } + + const id = randomUUID(); + inMemoryMessages.set(id, { ...event, id, initiatedAt: new Date() }); + return this.ok({ id }); + } + + private async evaluateAndAlert(messageDbId: string): Promise { + if (!this.usePrisma()) return; + + const msg = await prisma.bridgeMessage.findUnique({ where: { id: messageDbId } }); + if (!msg) return; + + const alerts = evaluateMessage({ + id: msg.id, + provider: msg.provider, + sourceChain: msg.sourceChain, + destinationChain: msg.destinationChain, + messageId: msg.messageId, + status: msg.status, + initiatedAt: msg.initiatedAt, + sourceConfirmedAt: msg.sourceConfirmedAt, + relayedAt: msg.relayedAt, + executedAt: msg.executedAt, + expectedDeliveryMs: msg.expectedDeliveryMs, + }); + + for (const alert of alerts) { + const existing = await prisma.bridgeAlert.findFirst({ + where: { messageId: messageDbId, alertType: alert.alertType, acknowledged: false }, + }); + if (existing) continue; + + await prisma.bridgeAlert.create({ data: alert }); + void dispatchBridgeAlert(alert); + } + + if (['initiated', 'source_confirmed', 'relayed'].includes(msg.status)) { + const age = Date.now() - msg.initiatedAt.getTime(); + if (age > msg.expectedDeliveryMs * 3) { + await prisma.bridgeMessage.update({ + where: { id: messageDbId }, + data: { status: 'stuck' }, + }); + } + } + } + + async pollAndReconcile(): Promise { + const events = await pollAllBridgeEvents(); + for (const event of events) { + await this.trackMessage(event); + } + + if (this.usePrisma()) { + const pending = await prisma.bridgeMessage.findMany({ + where: { status: { in: ['initiated', 'source_confirmed', 'relayed'] } }, + }); + for (const msg of pending) { + await this.evaluateAndAlert(msg.id); + } + } + } + + async retryMessage(messageId: string, initiatedBy?: string): Promise> { + if (!this.usePrisma()) { + return this.fail('Database required for retry', 503, 'DB_UNAVAILABLE'); + } + + const msg = await prisma.bridgeMessage.findUnique({ where: { id: messageId } }); + if (!msg) return this.notFoundFailure('BridgeMessage', messageId); + + const attemptCount = await prisma.bridgeRetry.count({ where: { messageId } }); + + const retry = await prisma.bridgeRetry.create({ + data: { + messageId, + attempt: attemptCount + 1, + status: 'pending', + initiatedBy, + }, + }); + + await prisma.bridgeMessage.update({ + where: { id: messageId }, + data: { status: 'initiated' }, + }); + + return this.ok({ retryId: retry.id }); + } + + async getHealth(): Promise> { + if (!this.usePrisma()) { + const msgs = Array.from(inMemoryMessages.values()); + return this.ok({ + totalMessages: msgs.length, + byStatus: {}, + byProvider: {}, + successRate: 0, + averageLatencyMs: 0, + stuckCount: 0, + pendingAlerts: 0, + }); + } + + const [total, byStatus, byProvider, completed, stuck, pendingAlerts, latencyAgg] = + await Promise.all([ + prisma.bridgeMessage.count(), + prisma.bridgeMessage.groupBy({ by: ['status'], _count: true }), + prisma.bridgeMessage.groupBy({ by: ['provider'], _count: true }), + prisma.bridgeMessage.count({ where: { status: 'destination_executed' } }), + prisma.bridgeMessage.count({ where: { status: 'stuck' } }), + prisma.bridgeAlert.count({ where: { acknowledged: false } }), + prisma.bridgeMessage.findMany({ + where: { status: 'destination_executed', executedAt: { not: null } }, + select: { initiatedAt: true, executedAt: true }, + take: 500, + }), + ]); + + const statusMap: Record = {}; + for (const s of byStatus) statusMap[s.status] = s._count; + + const providerMap: Record = {}; + for (const p of byProvider) providerMap[p.provider] = p._count; + + const latencies = latencyAgg + .filter((m) => m.executedAt) + .map((m) => m.executedAt!.getTime() - m.initiatedAt.getTime()); + const averageLatencyMs = + latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0; + + return this.ok({ + totalMessages: total, + byStatus: statusMap, + byProvider: providerMap, + successRate: total > 0 ? completed / total : 0, + averageLatencyMs, + stuckCount: stuck, + pendingAlerts, + }); + } + + async getAnalytics(days = 30): Promise> { + const since = new Date(); + since.setDate(since.getDate() - days); + + if (!this.usePrisma()) { + return this.ok({ + period: `${days}d`, + volume: 0, + successRate: 0, + averageLatencyMs: 0, + failureTrend: [], + byProvider: [], + }); + } + + const messages = await prisma.bridgeMessage.findMany({ + where: { initiatedAt: { gte: since } }, + }); + + const completed = messages.filter((m) => m.status === 'destination_executed'); + const failed = messages.filter((m) => ['failed', 'stuck', 'expired'].includes(m.status)); + + const latencies = completed + .filter((m) => m.executedAt) + .map((m) => m.executedAt!.getTime() - m.initiatedAt.getTime()); + + const trendMap = new Map(); + for (const msg of messages) { + const date = msg.initiatedAt.toISOString().slice(0, 10); + const entry = trendMap.get(date) ?? { failures: 0, total: 0 }; + entry.total++; + if (['failed', 'stuck', 'expired'].includes(msg.status)) entry.failures++; + trendMap.set(date, entry); + } + + const providers: BridgeProvider[] = ['wormhole', 'layerzero', 'axelar', 'custom']; + const byProvider = providers.map((provider) => { + const providerMsgs = messages.filter((m) => m.provider === provider); + const providerCompleted = providerMsgs.filter((m) => m.status === 'destination_executed'); + const providerLatencies = providerCompleted + .filter((m) => m.executedAt) + .map((m) => m.executedAt!.getTime() - m.initiatedAt.getTime()); + + return { + provider, + volume: providerMsgs.length, + successRate: providerMsgs.length > 0 ? providerCompleted.length / providerMsgs.length : 0, + averageLatencyMs: + providerLatencies.length > 0 + ? providerLatencies.reduce((a, b) => a + b, 0) / providerLatencies.length + : 0, + }; + }); + + return this.ok({ + period: `${days}d`, + volume: messages.length, + successRate: messages.length > 0 ? completed.length / messages.length : 0, + averageLatencyMs: + latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0, + failureTrend: Array.from(trendMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, v]) => ({ date, ...v })), + byProvider, + }); + } + + async listMessages(filters?: { + provider?: BridgeProvider; + status?: BridgeMessageStatus; + limit?: number; + }) { + const limit = filters?.limit ?? 50; + + if (!this.usePrisma()) { + return this.ok({ + messages: Array.from(inMemoryMessages.values()).slice(0, limit), + }); + } + + const messages = await prisma.bridgeMessage.findMany({ + where: { + ...(filters?.provider ? { provider: filters.provider } : {}), + ...(filters?.status ? { status: filters.status } : {}), + }, + orderBy: { initiatedAt: 'desc' }, + take: limit, + include: { alerts: { where: { acknowledged: false }, take: 5 } }, + }); + + return this.ok({ messages }); + } +} + +let instance: BridgeMonitorService | null = null; + +export function getBridgeMonitorService(): BridgeMonitorService { + if (!instance) instance = new BridgeMonitorService(); + return instance; +} diff --git a/backend/src/services/bridge-monitor/listeners/index.ts b/backend/src/services/bridge-monitor/listeners/index.ts new file mode 100644 index 00000000..afc5cd23 --- /dev/null +++ b/backend/src/services/bridge-monitor/listeners/index.ts @@ -0,0 +1,125 @@ +/** + * Per-bridge event listeners — Issue #475 + * + * Normalizes bridge events from Wormhole, LayerZero, Axelar, and custom bridges. + */ + +import type { BridgeMessageStatus, BridgeProvider } from '@prisma/client'; + +export interface BridgeEvent { + provider: BridgeProvider; + messageId: string; + sourceChain: string; + destinationChain: string; + status: BridgeMessageStatus; + txHashSource?: string; + txHashDestination?: string; + amount?: string; + tokenAddress?: string; + sender?: string; + recipient?: string; + gasCostSource?: string; + gasCostDestination?: string; + metadata?: Record; +} + +export interface BridgeListener { + provider: BridgeProvider; + poll(): Promise; +} + +class WormholeListener implements BridgeListener { + provider: BridgeProvider = 'wormhole'; + + async poll(): Promise { + const apiUrl = process.env.WORMHOLE_API_URL; + if (!apiUrl) return []; + + try { + const res = await fetch(`${apiUrl}/v1/signed_vaa`); + if (!res.ok) return []; + const data = (await res.json()) as { messages?: Array> }; + return (data.messages ?? []).slice(0, 50).map((m, i) => this.normalize(m, i)); + } catch { + return []; + } + } + + private normalize(raw: Record, index: number): BridgeEvent { + return { + provider: 'wormhole', + messageId: String(raw.sequence ?? raw.id ?? `wh-${index}-${Date.now()}`), + sourceChain: String(raw.emitterChain ?? 'unknown'), + destinationChain: String(raw.targetChain ?? 'unknown'), + status: raw.confirmed ? 'destination_executed' : 'relayed', + txHashSource: raw.txHash ? String(raw.txHash) : undefined, + metadata: raw, + }; + } +} + +class LayerZeroListener implements BridgeListener { + provider: BridgeProvider = 'layerzero'; + + async poll(): Promise { + const rpcUrl = process.env.LAYERZERO_RPC_URL ?? process.env.EVM_RPC_URL; + if (!rpcUrl) return []; + return []; + } +} + +class AxelarListener implements BridgeListener { + provider: BridgeProvider = 'axelar'; + + async poll(): Promise { + const apiUrl = process.env.AXELAR_API_URL; + if (!apiUrl) return []; + + try { + const res = await fetch(`${apiUrl}/cross-chain/transfers?status=pending`); + if (!res.ok) return []; + const data = (await res.json()) as { transfers?: Array> }; + return (data.transfers ?? []).slice(0, 50).map((t, i) => ({ + provider: 'axelar' as BridgeProvider, + messageId: String(t.id ?? `ax-${i}`), + sourceChain: String(t.sourceChain ?? 'unknown'), + destinationChain: String(t.destinationChain ?? 'unknown'), + status: (t.status === 'completed' ? 'destination_executed' : 'relayed') as BridgeMessageStatus, + amount: t.amount ? String(t.amount) : undefined, + sender: t.sender ? String(t.sender) : undefined, + recipient: t.recipient ? String(t.recipient) : undefined, + metadata: t, + })); + } catch { + return []; + } + } +} + +class CustomBridgeListener implements BridgeListener { + provider: BridgeProvider = 'custom'; + + async poll(): Promise { + return []; + } +} + +const listeners: BridgeListener[] = [ + new WormholeListener(), + new LayerZeroListener(), + new AxelarListener(), + new CustomBridgeListener(), +]; + +export function getBridgeListeners(): BridgeListener[] { + return listeners; +} + +export async function pollAllBridgeEvents(): Promise { + const results = await Promise.allSettled(listeners.map((l) => l.poll())); + const events: BridgeEvent[] = []; + for (const result of results) { + if (result.status === 'fulfilled') events.push(...result.value); + } + return events; +} diff --git a/backend/src/services/bridge.ts b/backend/src/services/bridge.ts index a91a4008..91eb03f0 100644 --- a/backend/src/services/bridge.ts +++ b/backend/src/services/bridge.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { getBridgeMonitorService } from './bridge-monitor/bridge-monitor.js'; export interface BridgeTransfer { id: string; @@ -88,9 +89,46 @@ export function createBridgeTransfer(input: { updatedAt: ts, }; transfers.set(transfer.id, transfer); + void syncBridgeMonitor(transfer, 'initiated'); return transfer; } +function mapBridgeStatus( + status: BridgeTransfer['status'], +): 'initiated' | 'source_confirmed' | 'relayed' | 'destination_executed' | 'failed' | 'stuck' { + switch (status) { + case 'created': + return 'initiated'; + case 'locked': + return 'source_confirmed'; + case 'relayed': + return 'relayed'; + case 'redeemed': + return 'destination_executed'; + case 'refunded': + case 'disputed': + return 'failed'; + default: + return 'stuck'; + } +} + +function syncBridgeMonitor(transfer: BridgeTransfer, status?: BridgeTransfer['status']): void { + void getBridgeMonitorService() + .trackMessage({ + provider: 'custom', + messageId: transfer.id, + sourceChain: transfer.fromChain, + destinationChain: transfer.toChain, + status: mapBridgeStatus(status ?? transfer.status), + amount: String(transfer.amount), + sender: transfer.sender, + recipient: transfer.recipient, + metadata: { hashlock: transfer.hashlock, route: transfer.route }, + }) + .catch(() => undefined); +} + export function transitionBridgeTransfer( id: string, next: BridgeTransfer['status'] @@ -104,6 +142,7 @@ export function transitionBridgeTransfer( } transfer.updatedAt = nowIso(); transfers.set(id, transfer); + syncBridgeMonitor(transfer, next); return transfer; } diff --git a/backend/src/services/contracts/upgrade-validator.ts b/backend/src/services/contracts/upgrade-validator.ts new file mode 100644 index 00000000..8c5ab50c --- /dev/null +++ b/backend/src/services/contracts/upgrade-validator.ts @@ -0,0 +1,363 @@ +/** + * upgrade-validator.ts — Issue #474 + * + * Validates smart contract upgrades before deployment: storage layout + * compatibility, fork simulation, admin permission checks, and smoke tests. + */ + +import { randomUUID } from 'node:crypto'; +import { ethers } from 'ethers'; +import type { ContractPlatform, UpgradeValidationStatus } from '@prisma/client'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { prisma } from '../../lib/prisma.js'; + +export interface StorageLayoutDiff { + type: 'reorder' | 'type_change' | 'slot_collision' | 'new_variable' | 'removed_variable'; + variable: string; + slot?: number; + oldType?: string; + newType?: string; + severity: 'error' | 'warning'; + message: string; +} + +export interface UpgradeValidationInput { + contractName: string; + platform: ContractPlatform; + network: string; + proxyAddress: string; + newImplementation: string; + previousImplementation?: string; + deployerAddress?: string; + timelockAddress?: string; + storageLayoutOld?: Array<{ name: string; type: string; slot: number }>; + storageLayoutNew?: Array<{ name: string; type: string; slot: number }>; +} + +export interface ValidationReport { + id: string; + upgradeId: string; + status: UpgradeValidationStatus; + storageLayoutDiff: StorageLayoutDiff[]; + simulationPassed: boolean; + smokeTestsPassed: boolean; + adminPreserved: boolean; + proxyAdminValid: boolean; + implementationVerified: boolean; + failures: string[]; + warnings: string[]; + durationMs: number; +} + +const PROXY_ADMIN_SLOT = '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103'; + +class UpgradeValidatorService extends BaseService { + private usePrisma(): boolean { + return Boolean(process.env.DATABASE_URL); + } + + diffStorageLayout( + oldLayout: Array<{ name: string; type: string; slot: number }>, + newLayout: Array<{ name: string; type: string; slot: number }>, + ): StorageLayoutDiff[] { + const diffs: StorageLayoutDiff[] = []; + const oldMap = new Map(oldLayout.map((v) => [v.name, v])); + const newMap = new Map(newLayout.map((v) => [v.name, v])); + const usedSlots = new Set(); + + for (const [name, newVar] of newMap) { + const oldVar = oldMap.get(name); + if (!oldVar) { + diffs.push({ + type: 'new_variable', + variable: name, + slot: newVar.slot, + newType: newVar.type, + severity: 'warning', + message: `New storage variable "${name}" at slot ${newVar.slot}`, + }); + continue; + } + + if (oldVar.slot !== newVar.slot) { + diffs.push({ + type: 'reorder', + variable: name, + slot: newVar.slot, + severity: 'error', + message: `Variable "${name}" moved from slot ${oldVar.slot} to ${newVar.slot}`, + }); + } + + if (oldVar.type !== newVar.type) { + diffs.push({ + type: 'type_change', + variable: name, + slot: newVar.slot, + oldType: oldVar.type, + newType: newVar.type, + severity: 'error', + message: `Variable "${name}" type changed from ${oldVar.type} to ${newVar.type}`, + }); + } + + if (usedSlots.has(newVar.slot)) { + diffs.push({ + type: 'slot_collision', + variable: name, + slot: newVar.slot, + severity: 'error', + message: `Slot collision at ${newVar.slot} for variable "${name}"`, + }); + } + usedSlots.add(newVar.slot); + } + + for (const [name, oldVar] of oldMap) { + if (!newMap.has(name)) { + diffs.push({ + type: 'removed_variable', + variable: name, + slot: oldVar.slot, + oldType: oldVar.type, + severity: 'error', + message: `Storage variable "${name}" removed from layout`, + }); + } + } + + return diffs; + } + + async checkProxyAdmin(proxyAddress: string, rpcUrl?: string): Promise<{ valid: boolean; admin?: string; renounced: boolean }> { + const url = rpcUrl ?? process.env.EVM_RPC_URL; + if (!url) return { valid: false, renounced: false }; + + try { + const provider = new ethers.JsonRpcProvider(url); + const adminSlot = await provider.getStorage(proxyAddress, PROXY_ADMIN_SLOT); + const adminAddress = ethers.getAddress('0x' + adminSlot.slice(-40)); + + const renounced = + adminAddress === ethers.ZeroAddress || + adminAddress === '0x0000000000000000000000000000000000000000'; + + return { valid: !renounced, admin: adminAddress, renounced }; + } catch { + return { valid: false, renounced: false }; + } + } + + async simulateUpgrade(input: UpgradeValidationInput): Promise<{ passed: boolean; forkBlock?: bigint; error?: string }> { + const rpcUrl = process.env.EVM_FORK_RPC_URL ?? process.env.EVM_RPC_URL; + if (!rpcUrl || input.platform !== 'evm') { + return { passed: input.platform === 'soroban', error: input.platform === 'evm' ? 'No fork RPC configured' : undefined }; + } + + try { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const blockNumber = BigInt(await provider.getBlockNumber()); + + const implCode = await provider.getCode(input.newImplementation); + if (implCode === '0x' || implCode.length <= 2) { + return { passed: false, forkBlock: blockNumber, error: 'New implementation has no bytecode' }; + } + + const proxyCode = await provider.getCode(input.proxyAddress); + if (proxyCode === '0x' || proxyCode.length <= 2) { + return { passed: false, forkBlock: blockNumber, error: 'Proxy has no bytecode' }; + } + + return { passed: true, forkBlock: blockNumber }; + } catch (err) { + return { + passed: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async runSmokeTests(input: UpgradeValidationInput): Promise<{ passed: boolean; failures: string[] }> { + const failures: string[] = []; + const rpcUrl = process.env.EVM_RPC_URL; + + if (input.platform === 'evm' && rpcUrl) { + try { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const implCode = await provider.getCode(input.newImplementation); + if (implCode.length < 10) failures.push('Implementation bytecode too small'); + + const proxy = new ethers.Contract( + input.proxyAddress, + ['function implementation() view returns (address)'], + provider, + ); + + try { + const currentImpl = await proxy.implementation(); + if (currentImpl.toLowerCase() === input.newImplementation.toLowerCase()) { + failures.push('Proxy already points to new implementation — verify this is intentional'); + } + } catch { + // UUPS proxies may not expose implementation() directly + } + } catch (err) { + failures.push(`Smoke test RPC error: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (input.platform === 'soroban') { + if (!process.env.SOROBAN_RPC_URL) { + failures.push('Soroban RPC not configured for sandbox simulation'); + } + } + + return { passed: failures.length === 0, failures }; + } + + async validateUpgrade(input: UpgradeValidationInput): Promise> { + const start = Date.now(); + const failures: string[] = []; + const warnings: string[] = []; + + const storageLayoutDiff = this.diffStorageLayout( + input.storageLayoutOld ?? [], + input.storageLayoutNew ?? [], + ); + + for (const diff of storageLayoutDiff) { + if (diff.severity === 'error') failures.push(diff.message); + else warnings.push(diff.message); + } + + const simulation = await this.simulateUpgrade(input); + if (!simulation.passed) { + failures.push(simulation.error ?? 'Fork simulation failed'); + } + + const smokeTests = await this.runSmokeTests(input); + failures.push(...smokeTests.failures); + + const proxyAdmin = await this.checkProxyAdmin(input.proxyAddress); + if (proxyAdmin.renounced) { + failures.push('Proxy admin ownership appears renounced — upgrade may be impossible'); + } + + const adminPreserved = !proxyAdmin.renounced; + const proxyAdminValid = proxyAdmin.valid; + const implementationVerified = simulation.passed; + const passed = failures.length === 0; + + const status: UpgradeValidationStatus = passed ? 'passed' : 'failed'; + const durationMs = Date.now() - start; + + let upgradeId = randomUUID(); + let reportId = randomUUID(); + + if (this.usePrisma()) { + const upgrade = await prisma.contractUpgrade.create({ + data: { + id: upgradeId, + contractName: input.contractName, + platform: input.platform, + network: input.network, + proxyAddress: input.proxyAddress, + previousImplementation: input.previousImplementation, + newImplementation: input.newImplementation, + deployerAddress: input.deployerAddress, + timelockAddress: input.timelockAddress, + status, + }, + }); + upgradeId = upgrade.id; + + const report = await prisma.upgradeValidationReport.create({ + data: { + id: reportId, + upgradeId, + status, + storageLayoutDiff: storageLayoutDiff as unknown as object, + simulationPassed: simulation.passed, + smokeTestsPassed: smokeTests.passed, + adminPreserved, + proxyAdminValid, + implementationVerified, + failures: failures.length ? failures : undefined, + warnings: warnings.length ? warnings : undefined, + forkBlockNumber: simulation.forkBlock, + durationMs, + }, + }); + reportId = report.id; + } + + return this.ok({ + id: reportId, + upgradeId, + status, + storageLayoutDiff, + simulationPassed: simulation.passed, + smokeTestsPassed: smokeTests.passed, + adminPreserved, + proxyAdminValid, + implementationVerified, + failures, + warnings, + durationMs, + }); + } + + async rollbackUpgrade(upgradeId: string): Promise> { + if (!this.usePrisma()) { + return this.fail('Database required for rollback', 503, 'DB_UNAVAILABLE'); + } + + const upgrade = await prisma.contractUpgrade.findUnique({ where: { id: upgradeId } }); + if (!upgrade) return this.notFoundFailure('ContractUpgrade', upgradeId); + if (!upgrade.previousImplementation) { + return this.validationFailure('No previous implementation recorded for rollback'); + } + + await prisma.contractUpgrade.update({ + where: { id: upgradeId }, + data: { status: 'rolled_back', rolledBackAt: new Date() }, + }); + + return this.ok({ rolledBack: true }); + } + + async getUpgradeHistory(limit = 20) { + if (!this.usePrisma()) return this.ok({ upgrades: [] }); + + const upgrades = await prisma.contractUpgrade.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + validationReports: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + + return this.ok({ + upgrades: upgrades.map((u) => ({ + id: u.id, + contractName: u.contractName, + platform: u.platform, + network: u.network, + proxyAddress: u.proxyAddress, + newImplementation: u.newImplementation, + status: u.status, + deployedAt: u.deployedAt?.toISOString() ?? null, + latestReport: u.validationReports[0] ?? null, + createdAt: u.createdAt.toISOString(), + })), + }); + } +} + +let instance: UpgradeValidatorService | null = null; + +export function getUpgradeValidatorService(): UpgradeValidatorService { + if (!instance) instance = new UpgradeValidatorService(); + return instance; +} diff --git a/frontend/app/accessibility/page.tsx b/frontend/app/[locale]/accessibility/page.tsx similarity index 100% rename from frontend/app/accessibility/page.tsx rename to frontend/app/[locale]/accessibility/page.tsx diff --git a/frontend/app/auth/page.tsx b/frontend/app/[locale]/auth/page.tsx similarity index 91% rename from frontend/app/auth/page.tsx rename to frontend/app/[locale]/auth/page.tsx index 3849ca77..5281d7e4 100644 --- a/frontend/app/auth/page.tsx +++ b/frontend/app/[locale]/auth/page.tsx @@ -1,12 +1,15 @@ 'use client'; import { motion } from 'framer-motion'; +import { useTranslations } from 'next-intl'; import { SocialLogin } from '@/components/auth/SocialLogin'; import { WalletConnect } from '@/components/auth/WalletConnect'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Wallet, Users } from 'lucide-react'; export default function AuthPage() { + const t = useTranslations('auth'); + return (

- Welcome to AgenticPay + {t('title')}

- Get paid instantly for your work + {t('subtitle')}

@@ -37,11 +40,11 @@ export default function AuthPage() { - Social Login + {t('socialLogin')} - Web3 Wallet + {t('walletConnect')} diff --git a/frontend/app/dashboard/DashboardCharts.tsx b/frontend/app/[locale]/dashboard/DashboardCharts.tsx similarity index 100% rename from frontend/app/dashboard/DashboardCharts.tsx rename to frontend/app/[locale]/dashboard/DashboardCharts.tsx diff --git a/frontend/app/[locale]/dashboard/admin/archival/page.tsx b/frontend/app/[locale]/dashboard/admin/archival/page.tsx new file mode 100644 index 00000000..da8534b2 --- /dev/null +++ b/frontend/app/[locale]/dashboard/admin/archival/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { RefreshCw, Archive, HardDrive, CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; + +interface ArchivalDashboard { + lastArchiveAt: string | null; + lastCid: string | null; + lastSizeBytes: number; + totalBatches: number; + completedBatches: number; + failedBatches: number; + chains: Array<{ + chain: string; + lastBatchDate: string | null; + lastCid: string | null; + recordCount: number; + status: string; + }>; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export default function ArchivalDashboardPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDashboard = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/api/v1/archival/dashboard`); + if (!res.ok) throw new Error('Failed to load archival dashboard'); + setData(await res.json()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchDashboard(); + }, [fetchDashboard]); + + return ( +
+
+
+

On-Chain Archival

+

+ Daily IPFS backups with integrity verification and 7-year retention +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ } + /> + } + /> + } + /> + } + /> +
+ + + + Per-Chain Status + + {data?.totalBatches ?? 0} total batches · {data?.failedBatches ?? 0} failed + + + +
+ {(data?.chains ?? []).map((chain) => ( +
+
+

{chain.chain}

+

+ {chain.recordCount} records · {chain.status} +

+
+
+

{chain.lastBatchDate ? new Date(chain.lastBatchDate).toLocaleDateString() : '—'}

+

{chain.lastCid ? `${chain.lastCid.slice(0, 10)}…` : '—'}

+
+
+ ))} +
+
+
+
+ ); +} + +function SummaryCard({ + title, + value, + icon, +}: { + title: string; + value: string | number; + icon: React.ReactNode; +}) { + return ( + + + {title} + {icon} + + +
{value}
+
+
+ ); +} diff --git a/frontend/app/dashboard/admin/audit-log/page.tsx b/frontend/app/[locale]/dashboard/admin/audit-log/page.tsx similarity index 100% rename from frontend/app/dashboard/admin/audit-log/page.tsx rename to frontend/app/[locale]/dashboard/admin/audit-log/page.tsx diff --git a/frontend/app/dashboard/admin/contracts/page.tsx b/frontend/app/[locale]/dashboard/admin/contracts/page.tsx similarity index 100% rename from frontend/app/dashboard/admin/contracts/page.tsx rename to frontend/app/[locale]/dashboard/admin/contracts/page.tsx diff --git a/frontend/app/[locale]/dashboard/admin/contracts/upgrades/page.tsx b/frontend/app/[locale]/dashboard/admin/contracts/upgrades/page.tsx new file mode 100644 index 00000000..01c8c389 --- /dev/null +++ b/frontend/app/[locale]/dashboard/admin/contracts/upgrades/page.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + AlertTriangle, + CheckCircle2, + Clock, + RefreshCw, + Shield, + XCircle, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; + +interface ValidationReport { + id: string; + status: string; + simulationPassed: boolean; + smokeTestsPassed: boolean; + adminPreserved: boolean; + proxyAdminValid: boolean; + implementationVerified: boolean; + failures?: string[]; + durationMs?: number; + createdAt: string; +} + +interface UpgradeRecord { + id: string; + contractName: string; + platform: string; + network: string; + proxyAddress: string; + newImplementation: string; + status: string; + deployedAt: string | null; + latestReport: ValidationReport | null; + createdAt: string; +} + +export default function UpgradeSafetyPage() { + const t = useTranslations('upgradeSafety'); + const [upgrades, setUpgrades] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchHistory = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/api/v1/admin/contracts/upgrade/history`); + if (!res.ok) throw new Error('Failed to load upgrade history'); + const data = (await res.json()) as { upgrades: UpgradeRecord[] }; + setUpgrades(data.upgrades ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchHistory(); + }, [fetchHistory]); + + return ( +
+
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ u.status === 'passed').length} + icon={} + /> + u.status === 'failed').length} + icon={} + /> + u.status === 'rolled_back').length} + icon={} + /> +
+ + + + {t('history')} + {t('historyDesc')} + + + {upgrades.length === 0 && !loading && ( +

{t('empty')}

+ )} + {upgrades.map((upgrade) => ( +
+
+
+

+ {upgrade.contractName} · {upgrade.network} +

+

+ {upgrade.proxyAddress} +

+
+ +
+ + {upgrade.latestReport && ( +
+ + + + + +
+ )} + + {upgrade.latestReport?.failures?.length ? ( +
    + {upgrade.latestReport.failures.map((f) => ( +
  • {f}
  • + ))} +
+ ) : null} + +
+ + {new Date(upgrade.createdAt).toLocaleString()} + {upgrade.latestReport?.durationMs != null && ( + · {upgrade.latestReport.durationMs}ms + )} +
+
+ ))} +
+
+
+ ); +} + +function SummaryCard({ + title, + value, + icon, +}: { + title: string; + value: number; + icon: React.ReactNode; +}) { + return ( + + + {title} + {icon} + + +
{value}
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + passed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + rolled_back: 'bg-amber-100 text-amber-800', + pending: 'bg-gray-100 text-gray-800', + running: 'bg-blue-100 text-blue-800', + }; + return ( + + {status.replace(/_/g, ' ')} + + ); +} + +function Check({ label, ok }: { label: string; ok: boolean }) { + return ( +
+ {ok ? ( + + ) : ( + + )} + {label} +
+ ); +} diff --git a/frontend/app/dashboard/admin/exports/page.tsx b/frontend/app/[locale]/dashboard/admin/exports/page.tsx similarity index 100% rename from frontend/app/dashboard/admin/exports/page.tsx rename to frontend/app/[locale]/dashboard/admin/exports/page.tsx diff --git a/frontend/app/[locale]/dashboard/admin/i18n/page.tsx b/frontend/app/[locale]/dashboard/admin/i18n/page.tsx new file mode 100644 index 00000000..7af10385 --- /dev/null +++ b/frontend/app/[locale]/dashboard/admin/i18n/page.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import en from '@/messages/en.json'; +import es from '@/messages/es.json'; +import fr from '@/messages/fr.json'; +import ja from '@/messages/ja.json'; +import ar from '@/messages/ar.json'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { localeLabels, locales, type AppLocale } from '@/i18n/routing'; + +const localeMessages: Record> = { + en, + es, + fr, + ja, + ar, +}; + +function flattenKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + keys.push(...flattenKeys(value as Record, path)); + } else { + keys.push(path); + } + } + return keys; +} + +export default function TranslationManagementPage() { + const t = useTranslations('i18nAdmin'); + const baseKeys = useMemo(() => flattenKeys(en as Record), []); + + const coverage = useMemo(() => { + return locales.map((locale) => { + const messages = localeMessages[locale]; + const keys = flattenKeys(messages); + const keySet = new Set(keys); + const missing = baseKeys.filter((k) => !keySet.has(k)); + return { + locale, + total: baseKeys.length, + translated: baseKeys.length - missing.length, + missing, + coveragePct: ((baseKeys.length - missing.length) / baseKeys.length) * 100, + }; + }); + }, [baseKeys]); + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ {coverage.map((row) => ( + + + {localeLabels[row.locale]} + + {t('coverage')}: {row.coveragePct.toFixed(0)}% + + + +
+ {t('locale')} + {row.locale} +
+
+ {t('missingKeys')} + 0 ? 'text-amber-600 font-medium' : ''}> + {row.missing.length} + +
+ {row.missing.length > 0 && ( +
    + {row.missing.slice(0, 10).map((key) => ( +
  • + {key} +
  • + ))} + {row.missing.length > 10 && ( +
  • …and {row.missing.length - 10} more
  • + )} +
+ )} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/app/dashboard/admin/plugins/page.tsx b/frontend/app/[locale]/dashboard/admin/plugins/page.tsx similarity index 100% rename from frontend/app/dashboard/admin/plugins/page.tsx rename to frontend/app/[locale]/dashboard/admin/plugins/page.tsx diff --git a/frontend/app/dashboard/analytics/loading.tsx b/frontend/app/[locale]/dashboard/analytics/loading.tsx similarity index 100% rename from frontend/app/dashboard/analytics/loading.tsx rename to frontend/app/[locale]/dashboard/analytics/loading.tsx diff --git a/frontend/app/dashboard/analytics/page.tsx b/frontend/app/[locale]/dashboard/analytics/page.tsx similarity index 100% rename from frontend/app/dashboard/analytics/page.tsx rename to frontend/app/[locale]/dashboard/analytics/page.tsx diff --git a/frontend/app/dashboard/batch/page.tsx b/frontend/app/[locale]/dashboard/batch/page.tsx similarity index 100% rename from frontend/app/dashboard/batch/page.tsx rename to frontend/app/[locale]/dashboard/batch/page.tsx diff --git a/frontend/app/dashboard/disputes/[id]/page.tsx b/frontend/app/[locale]/dashboard/disputes/[id]/page.tsx similarity index 100% rename from frontend/app/dashboard/disputes/[id]/page.tsx rename to frontend/app/[locale]/dashboard/disputes/[id]/page.tsx diff --git a/frontend/app/dashboard/disputes/arbitrator/page.tsx b/frontend/app/[locale]/dashboard/disputes/arbitrator/page.tsx similarity index 100% rename from frontend/app/dashboard/disputes/arbitrator/page.tsx rename to frontend/app/[locale]/dashboard/disputes/arbitrator/page.tsx diff --git a/frontend/app/dashboard/disputes/new/page.tsx b/frontend/app/[locale]/dashboard/disputes/new/page.tsx similarity index 100% rename from frontend/app/dashboard/disputes/new/page.tsx rename to frontend/app/[locale]/dashboard/disputes/new/page.tsx diff --git a/frontend/app/dashboard/disputes/page.tsx b/frontend/app/[locale]/dashboard/disputes/page.tsx similarity index 100% rename from frontend/app/dashboard/disputes/page.tsx rename to frontend/app/[locale]/dashboard/disputes/page.tsx diff --git a/frontend/app/dashboard/error.tsx b/frontend/app/[locale]/dashboard/error.tsx similarity index 100% rename from frontend/app/dashboard/error.tsx rename to frontend/app/[locale]/dashboard/error.tsx diff --git a/frontend/app/dashboard/escrow/page.tsx b/frontend/app/[locale]/dashboard/escrow/page.tsx similarity index 100% rename from frontend/app/dashboard/escrow/page.tsx rename to frontend/app/[locale]/dashboard/escrow/page.tsx diff --git a/frontend/app/dashboard/forms/[id]/analytics/page.tsx b/frontend/app/[locale]/dashboard/forms/[id]/analytics/page.tsx similarity index 100% rename from frontend/app/dashboard/forms/[id]/analytics/page.tsx rename to frontend/app/[locale]/dashboard/forms/[id]/analytics/page.tsx diff --git a/frontend/app/dashboard/forms/page.tsx b/frontend/app/[locale]/dashboard/forms/page.tsx similarity index 100% rename from frontend/app/dashboard/forms/page.tsx rename to frontend/app/[locale]/dashboard/forms/page.tsx diff --git a/frontend/app/dashboard/invoices/[id]/page.tsx b/frontend/app/[locale]/dashboard/invoices/[id]/page.tsx similarity index 100% rename from frontend/app/dashboard/invoices/[id]/page.tsx rename to frontend/app/[locale]/dashboard/invoices/[id]/page.tsx diff --git a/frontend/app/dashboard/invoices/loading.tsx b/frontend/app/[locale]/dashboard/invoices/loading.tsx similarity index 100% rename from frontend/app/dashboard/invoices/loading.tsx rename to frontend/app/[locale]/dashboard/invoices/loading.tsx diff --git a/frontend/app/dashboard/invoices/page.tsx b/frontend/app/[locale]/dashboard/invoices/page.tsx similarity index 100% rename from frontend/app/dashboard/invoices/page.tsx rename to frontend/app/[locale]/dashboard/invoices/page.tsx diff --git a/frontend/app/[locale]/dashboard/layout.tsx b/frontend/app/[locale]/dashboard/layout.tsx new file mode 100644 index 00000000..e5b7cdd4 --- /dev/null +++ b/frontend/app/[locale]/dashboard/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import { DashboardAuthGuard } from '@/components/layout/DashboardAuthGuard'; + +export const metadata: Metadata = { + title: { + template: '%s | AgenticPay Dashboard', + default: 'Dashboard | AgenticPay', + }, + description: 'Manage your projects, invoices, payments, and real-time analytics.', + robots: { index: false, follow: false }, +}; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/app/dashboard/loading.tsx b/frontend/app/[locale]/dashboard/loading.tsx similarity index 100% rename from frontend/app/dashboard/loading.tsx rename to frontend/app/[locale]/dashboard/loading.tsx diff --git a/frontend/app/[locale]/dashboard/monitoring/bridges/page.tsx b/frontend/app/[locale]/dashboard/monitoring/bridges/page.tsx new file mode 100644 index 00000000..bc3666f5 --- /dev/null +++ b/frontend/app/[locale]/dashboard/monitoring/bridges/page.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { RefreshCw, AlertTriangle, CheckCircle2, Clock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; + +interface BridgeHealth { + totalMessages: number; + successRate: number; + averageLatencyMs: number; + stuckCount: number; + pendingAlerts: number; + byProvider: Record; + byStatus: Record; +} + +interface ProviderStats { + provider: string; + volume: number; + successRate: number; + averageLatencyMs: number; +} + +interface BridgeAnalytics { + volume: number; + successRate: number; + averageLatencyMs: number; + byProvider: ProviderStats[]; +} + +function formatLatency(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60_000).toFixed(1)}m`; +} + +function formatPercent(rate: number): string { + return `${(rate * 100).toFixed(1)}%`; +} + +export default function BridgeMonitoringPage() { + const t = useTranslations('bridgeMonitor'); + const [health, setHealth] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [healthRes, analyticsRes] = await Promise.all([ + fetch(`${API_BASE}/api/v1/bridge/monitor/health`), + fetch(`${API_BASE}/api/v1/bridge/monitor/analytics?days=30`), + ]); + + if (!healthRes.ok || !analyticsRes.ok) { + throw new Error('Failed to load bridge monitoring data'); + } + + setHealth(await healthRes.json()); + setAnalytics(await analyticsRes.json()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + return ( +
+
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ } + /> + } + /> + } + /> + } + highlight={(health?.stuckCount ?? 0) > 0} + /> +
+ +
+ + + {t('byProvider')} + {t('health')} + + +
+ {(analytics?.byProvider ?? []).map((provider) => ( +
+
+

{provider.provider}

+

+ {t('volume')}: {provider.volume} · {t('successRate')}:{' '} + {formatPercent(provider.successRate)} +

+
+ + {formatLatency(provider.averageLatencyMs)} + +
+ ))} + {!analytics?.byProvider?.length && !loading && ( +

No bridge activity recorded yet.

+ )} +
+
+
+ + + + {t('pendingAlerts')} + + {health?.pendingAlerts ?? 0} unacknowledged alert(s) + + + + {health?.byStatus && Object.keys(health.byStatus).length > 0 ? ( +
+ {Object.entries(health.byStatus).map(([status, count]) => ( +
+ {status.replace(/_/g, ' ')} + {count} +
+ ))} +
+ ) : ( +

No status breakdown available.

+ )} +
+
+
+
+ ); +} + +function MetricCard({ + title, + value, + icon, + highlight = false, +}: { + title: string; + value: string | number; + icon: React.ReactNode; + highlight?: boolean; +}) { + return ( + + + {title} + {icon} + + +
{value}
+
+
+ ); +} diff --git a/frontend/app/dashboard/multisig/page.tsx b/frontend/app/[locale]/dashboard/multisig/page.tsx similarity index 100% rename from frontend/app/dashboard/multisig/page.tsx rename to frontend/app/[locale]/dashboard/multisig/page.tsx diff --git a/frontend/app/dashboard/output.txt b/frontend/app/[locale]/dashboard/output.txt similarity index 100% rename from frontend/app/dashboard/output.txt rename to frontend/app/[locale]/dashboard/output.txt diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx similarity index 96% rename from frontend/app/dashboard/page.tsx rename to frontend/app/[locale]/dashboard/page.tsx index 10abc0e6..e0636a44 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -1,6 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; +import { useTranslations } from 'next-intl'; import { useDashboardData } from '@/lib/hooks/useDashboardData'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DollarSign, Clock, Folder, CheckCircle2, TrendingUp } from 'lucide-react'; @@ -27,14 +28,15 @@ const DashboardCharts = dynamic(() => import('./DashboardCharts'), { }); export default function DashboardPage() { + const t = useTranslations('dashboard'); const { stats, recentActivity, loading } = useDashboardData(); if (loading) { return (
-

Dashboard

-

Welcome back! Here's your overview.

+

{t('title')}

+

{t('welcome')}

@@ -75,8 +77,8 @@ export default function DashboardPage() { return (
-

Dashboard

-

Welcome back! Here's your overview.

+

{t('title')}

+

{t('welcome')}

{/* Stats Grid */} diff --git a/frontend/app/dashboard/payments/loading.tsx b/frontend/app/[locale]/dashboard/payments/loading.tsx similarity index 100% rename from frontend/app/dashboard/payments/loading.tsx rename to frontend/app/[locale]/dashboard/payments/loading.tsx diff --git a/frontend/app/dashboard/payments/page.tsx b/frontend/app/[locale]/dashboard/payments/page.tsx similarity index 100% rename from frontend/app/dashboard/payments/page.tsx rename to frontend/app/[locale]/dashboard/payments/page.tsx diff --git a/frontend/app/dashboard/payments/qr/page.tsx b/frontend/app/[locale]/dashboard/payments/qr/page.tsx similarity index 100% rename from frontend/app/dashboard/payments/qr/page.tsx rename to frontend/app/[locale]/dashboard/payments/qr/page.tsx diff --git a/frontend/app/dashboard/projects/[id]/page.tsx b/frontend/app/[locale]/dashboard/projects/[id]/page.tsx similarity index 100% rename from frontend/app/dashboard/projects/[id]/page.tsx rename to frontend/app/[locale]/dashboard/projects/[id]/page.tsx diff --git a/frontend/app/dashboard/projects/loading.tsx b/frontend/app/[locale]/dashboard/projects/loading.tsx similarity index 100% rename from frontend/app/dashboard/projects/loading.tsx rename to frontend/app/[locale]/dashboard/projects/loading.tsx diff --git a/frontend/app/dashboard/projects/new/page.tsx b/frontend/app/[locale]/dashboard/projects/new/page.tsx similarity index 100% rename from frontend/app/dashboard/projects/new/page.tsx rename to frontend/app/[locale]/dashboard/projects/new/page.tsx diff --git a/frontend/app/dashboard/projects/page.tsx b/frontend/app/[locale]/dashboard/projects/page.tsx similarity index 100% rename from frontend/app/dashboard/projects/page.tsx rename to frontend/app/[locale]/dashboard/projects/page.tsx diff --git a/frontend/app/dashboard/rate-limits/page.tsx b/frontend/app/[locale]/dashboard/rate-limits/page.tsx similarity index 100% rename from frontend/app/dashboard/rate-limits/page.tsx rename to frontend/app/[locale]/dashboard/rate-limits/page.tsx diff --git a/frontend/app/dashboard/security/page.tsx b/frontend/app/[locale]/dashboard/security/page.tsx similarity index 100% rename from frontend/app/dashboard/security/page.tsx rename to frontend/app/[locale]/dashboard/security/page.tsx diff --git a/frontend/app/dashboard/tax-reports/page.tsx b/frontend/app/[locale]/dashboard/tax-reports/page.tsx similarity index 100% rename from frontend/app/dashboard/tax-reports/page.tsx rename to frontend/app/[locale]/dashboard/tax-reports/page.tsx diff --git a/frontend/app/dashboard/vaults/page.tsx b/frontend/app/[locale]/dashboard/vaults/page.tsx similarity index 100% rename from frontend/app/dashboard/vaults/page.tsx rename to frontend/app/[locale]/dashboard/vaults/page.tsx diff --git a/frontend/app/dashboard/verification/page.tsx b/frontend/app/[locale]/dashboard/verification/page.tsx similarity index 100% rename from frontend/app/dashboard/verification/page.tsx rename to frontend/app/[locale]/dashboard/verification/page.tsx diff --git a/frontend/app/dashboard/webhooks/page.tsx b/frontend/app/[locale]/dashboard/webhooks/page.tsx similarity index 100% rename from frontend/app/dashboard/webhooks/page.tsx rename to frontend/app/[locale]/dashboard/webhooks/page.tsx diff --git a/frontend/app/forms/[id]/page.tsx b/frontend/app/[locale]/forms/[id]/page.tsx similarity index 100% rename from frontend/app/forms/[id]/page.tsx rename to frontend/app/[locale]/forms/[id]/page.tsx diff --git a/frontend/app/forms/embed/[id]/page.tsx b/frontend/app/[locale]/forms/embed/[id]/page.tsx similarity index 100% rename from frontend/app/forms/embed/[id]/page.tsx rename to frontend/app/[locale]/forms/embed/[id]/page.tsx diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx new file mode 100644 index 00000000..93405023 --- /dev/null +++ b/frontend/app/[locale]/layout.tsx @@ -0,0 +1,89 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { routing, rtlLocales, type AppLocale } from '@/i18n/routing'; +import { Providers } from '@/components/providers'; +import PWAWrapper from '@/components/PWAWrapper'; +import { OfflineProvider } from '@/components/offline/OfflineProvider'; +import { WebVitals } from '@/components/WebVitals'; + +const APP_DOMAIN = process.env.NEXT_PUBLIC_API_URL || 'https://agenticpay.com'; +const CDN_DOMAIN = process.env.NEXT_PUBLIC_IMAGE_CDN_DOMAIN || 'cdn.agenticpay.com'; +const RPC_DOMAIN = process.env.NEXT_PUBLIC_RPC_URL || 'https://rpc.agenticpay.com'; + +type Props = { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'metadata' }); + + const languages: Record = {}; + for (const loc of routing.locales) { + languages[loc] = loc === routing.defaultLocale ? '/' : `/${loc}`; + } + + return { + title: t('title'), + description: t('description'), + alternates: { languages }, + openGraph: { + title: t('title'), + description: t('description'), + type: 'website', + locale, + }, + }; +} + +export default async function LocaleLayout({ children, params }: Props) { + const { locale } = await params; + + if (!routing.locales.includes(locale as AppLocale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + const dir = rtlLocales.includes(locale as AppLocale) ? 'rtl' : 'ltr'; + + return ( + + + + + + + + + + + + + + + + {children} + + + + + + + + ); +} diff --git a/frontend/app/logs/page.tsx b/frontend/app/[locale]/logs/page.tsx similarity index 100% rename from frontend/app/logs/page.tsx rename to frontend/app/[locale]/logs/page.tsx diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/[locale]/onboarding/page.tsx similarity index 100% rename from frontend/app/onboarding/page.tsx rename to frontend/app/[locale]/onboarding/page.tsx diff --git a/frontend/app/page.tsx b/frontend/app/[locale]/page.tsx similarity index 100% rename from frontend/app/page.tsx rename to frontend/app/[locale]/page.tsx diff --git a/frontend/app/security/signature-safety/page.tsx b/frontend/app/[locale]/security/signature-safety/page.tsx similarity index 91% rename from frontend/app/security/signature-safety/page.tsx rename to frontend/app/[locale]/security/signature-safety/page.tsx index b7ce93cf..7fc95049 100644 --- a/frontend/app/security/signature-safety/page.tsx +++ b/frontend/app/[locale]/security/signature-safety/page.tsx @@ -1,4 +1,4 @@ -import { SIGNATURE_SAFETY_NOTICE } from '@/lib/signatures'; +import { SIGNATURE_SAFETY_NOTICE } from '@/lib/signature-notice'; export default function SignatureSafetyPage() { return ( diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx deleted file mode 100644 index 5b3dc998..00000000 --- a/frontend/app/dashboard/layout.tsx +++ /dev/null @@ -1,106 +0,0 @@ - -'use client'; - -import React, { useState } from 'react'; -import { useAuthStore } from '@/store/useAuthStore'; -import { usePathname, useRouter } from 'next/navigation'; -import Sidebar from '@/components/layout/Sidebar'; -import { useEffect, useRef } from 'react'; // Added useRef here -import { Header } from '@/components/layout/Header'; -import { ErrorBoundary } from '@/components/errors/ErrorBoundary'; - -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const router = useRouter(); - const pathname = usePathname(); - - /* ================================ - ✅ STEP 1: SIDEBAR STATE (NEW) - ================================= */ - const [sidebarOpen, setSidebarOpen] = useState(false); - - const toggleSidebar = () => { - setSidebarOpen((prev) => !prev); - }; - - /* ================================ - EXISTING SCROLL LOGIC (UNCHANGED) - ================================= */ - const mainRef = React.useRef(null); - const scrollPositions = React.useRef>({}); - - useEffect(() => { - if (!isAuthenticated) { - router.push('/auth'); - } - }, [isAuthenticated, router]); - - // Save scroll position - useEffect(() => { - const main = mainRef.current; - if (!main) return; - - const handleScroll = () => { - scrollPositions.current[pathname] = main.scrollTop; - }; - - main.addEventListener('scroll', handleScroll); - return () => main.removeEventListener('scroll', handleScroll); - }, [pathname]); - - // Restore scroll position - useEffect(() => { - const main = mainRef.current; - if (!main) return; - - const saved = scrollPositions.current[pathname]; - main.scrollTop = saved ?? 0; - }, [pathname]); - - if (!isAuthenticated) { - return null; - } - - return ( -
- {/* ✅ Sidebar now controlled */} - - -
- {/* ✅ Header can open sidebar */} -
- -
- - {children} - -
-
-
- ); -} -import type { Metadata } from 'next'; -import { DashboardAuthGuard } from '@/components/layout/DashboardAuthGuard'; - -export const metadata: Metadata = { - title: { - template: '%s | AgenticPay Dashboard', - default: 'Dashboard | AgenticPay', - }, - description: 'Manage your projects, invoices, payments, and real-time analytics.', - robots: { index: false, follow: false }, -}; - -export default function DashboardLayout({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6502f4e1..4ab838a6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -188,3 +188,18 @@ box-shadow: none !important; } } + +/* RTL layout support — Issue #476 */ +[dir='rtl'] { + text-align: start; +} + +[dir='rtl'] .sidebar-mirror { + inset-inline-start: 0; + inset-inline-end: auto; +} + +[dir='rtl'] .dropdown-end { + inset-inline-end: 0; + inset-inline-start: auto; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 9e800e60..3f523472 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,66 +1,9 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { Providers } from "@/components/providers"; -import PWAWrapper from "@/components/PWAWrapper"; -import { LanguageProvider } from "@/components/providers/LanguageProvider"; -import { OfflineProvider } from "@/components/offline/OfflineProvider"; -import { WebVitals } from "@/components/WebVitals"; +import './globals.css'; -const APP_DOMAIN = process.env.NEXT_PUBLIC_API_URL || "https://agenticpay.com"; -const RPC_DOMAIN = process.env.NEXT_PUBLIC_RPC_URL || "https://rpc.agenticpay.com"; -const CDN_DOMAIN = process.env.NEXT_PUBLIC_IMAGE_CDN_DOMAIN || "cdn.agenticpay.com"; - -export const metadata: Metadata = { - title: "AgenticPay - Get Paid Instantly for Your Work", - description: "Secure, fast, and transparent payments for freelancers powered by blockchain technology.", - manifest: "/manifest.webmanifest", - keywords: ["freelancer", "payments", "blockchain", "crypto", "web3", "escrow", "milestones"], - authors: [{ name: "AgenticPay" }], - openGraph: { - title: "AgenticPay - Get Paid Instantly for Your Work", - description: "Secure, fast, and transparent payments for freelancers powered by blockchain technology.", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "AgenticPay - Get Paid Instantly for Your Work", - description: "Secure, fast, and transparent payments for freelancers powered by blockchain technology.", - }, - other: { - "link-critical": "true", - }, +type Props = { + children: React.ReactNode; }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - - - - - - - - - - - - - {children} - - - - - - - - ); +export default function RootLayout({ children }: Props) { + return children; } diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts new file mode 100644 index 00000000..b2968f43 --- /dev/null +++ b/frontend/app/sitemap.ts @@ -0,0 +1,28 @@ +import type { MetadataRoute } from 'next/server'; +import { locales, defaultLocale } from '@/i18n/routing'; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://agenticpay.com'; + +const PUBLIC_PATHS = ['', '/auth', '/dashboard', '/dashboard/monitoring/bridges']; + +export default function sitemap(): MetadataRoute.Sitemap { + const entries: MetadataRoute.Sitemap = []; + + for (const path of PUBLIC_PATHS) { + const languages: Record = {}; + for (const locale of locales) { + languages[locale] = + locale === defaultLocale ? `${BASE_URL}${path}` : `${BASE_URL}/${locale}${path}`; + } + + entries.push({ + url: `${BASE_URL}${path}`, + lastModified: new Date(), + changeFrequency: path === '' ? 'weekly' : 'daily', + priority: path === '' ? 1 : 0.7, + alternates: { languages }, + }); + } + + return entries; +} diff --git a/frontend/components/common/locale-switcher.tsx b/frontend/components/common/locale-switcher.tsx new file mode 100644 index 00000000..347b243e --- /dev/null +++ b/frontend/components/common/locale-switcher.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { Globe } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { localeLabels, locales, type AppLocale } from '@/i18n/routing'; + +interface LocaleSwitcherProps { + compact?: boolean; +} + +export function LocaleSwitcher({ compact = false }: LocaleSwitcherProps) { + const t = useTranslations('language'); + const locale = useLocale() as AppLocale; + const router = useRouter(); + const pathname = usePathname(); + + const switchLocale = (nextLocale: AppLocale) => { + router.replace(pathname, { locale: nextLocale }); + }; + + const resetToBrowser = () => { + if (typeof navigator === 'undefined') return; + const langs = navigator.languages?.length ? navigator.languages : [navigator.language]; + for (const lang of langs) { + const base = lang.split('-')[0] as AppLocale; + if (locales.includes(base)) { + switchLocale(base); + return; + } + } + switchLocale('en'); + }; + + return ( + + + + + + + + {t('changeLanguage')} + + + + {locales.map((code) => ( + switchLocale(code)} + className="flex items-center justify-between" + > + {localeLabels[code]} + {code === locale && } + + ))} + + + + {t('resetToBrowser')} + + + + ); +} diff --git a/frontend/components/landing/HomePageClient.tsx b/frontend/components/landing/HomePageClient.tsx index 497a25cd..593f87c4 100644 --- a/frontend/components/landing/HomePageClient.tsx +++ b/frontend/components/landing/HomePageClient.tsx @@ -1,8 +1,9 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { motion } from "framer-motion"; -import { ArrowRight, Shield, Zap, Wallet, CheckCircle2 } from "lucide-react"; +import { motion } from 'framer-motion'; +import { useTranslations } from 'next-intl'; +import { ArrowRight, Shield, Zap, Wallet, CheckCircle2 } from 'lucide-react'; +import { Link } from '@/i18n/navigation'; import { Button } from "@/components/ui/button"; import { Navbar } from "@/components/landing/Navbar"; import type { LandingSnapshot } from "@/lib/server/public-cache"; @@ -12,6 +13,10 @@ interface HomePageClientProps { } export function HomePageClient({ snapshot }: HomePageClientProps) { + const t = useTranslations('landing'); + const tCommon = useTranslations('common'); + const tMeta = useTranslations('metadata'); + return (
@@ -31,40 +36,39 @@ export function HomePageClient({ snapshot }: HomePageClientProps) { className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-100 text-blue-700 text-sm font-medium mb-8" > - Secure / Fast / Transparent + {t('badge')}

- Get Paid Instantly for + {t('headline')} - Your Work + {t('headlineAccent')}

- AgenticPay revolutionizes freelancer payments with blockchain technology. - Get paid instantly, securely, and transparently. + {t('subheadline')}

- - - - + + + +
- + -
diff --git a/frontend/components/landing/Navbar.tsx b/frontend/components/landing/Navbar.tsx index 12975de7..bfa8a753 100644 --- a/frontend/components/landing/Navbar.tsx +++ b/frontend/components/landing/Navbar.tsx @@ -1,14 +1,17 @@ 'use client'; import { useState, useEffect } from 'react'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; +import { useTranslations } from 'next-intl'; import { motion, AnimatePresence } from 'framer-motion'; import { Menu, X, Wallet } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { usePathname } from 'next/navigation'; -import { LanguageSwitcher } from '../language/LanguageSwitcher'; +import { usePathname } from '@/i18n/navigation'; +import { LocaleSwitcher } from '../common/locale-switcher'; export function Navbar() { + const t = useTranslations('nav'); + const tCommon = useTranslations('common'); const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const pathname = usePathname(); @@ -23,9 +26,9 @@ export function Navbar() { }, []); const navLinks = [ - { name: 'Features', href: '#features' }, - { name: 'About', href: '#how-it-works' }, - { name: 'Pricing', href: '#pricing' }, + { name: t('features'), href: '#features' as const }, + { name: t('about'), href: '#how-it-works' as const }, + { name: t('pricing'), href: '#pricing' as const }, ]; return ( @@ -72,7 +75,7 @@ export function Navbar() { {/* CTA Button & Mobile Menu Toggle */}
- +
)} - +
diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 98f15278..90ad96d1 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -1,26 +1,32 @@ 'use client'; -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { LayoutDashboard, Folder, FileText, Wallet, Scale, Menu, X, QrCode } from 'lucide-react'; +import { Link, usePathname, useRouter } from '@/i18n/navigation'; +import { LayoutDashboard, Folder, FileText, Wallet, Scale, Menu, X, QrCode, Activity, Languages, Archive, ShieldCheck } from 'lucide-react'; import { useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -const navigation = [ - { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, - { name: 'Projects', href: '/dashboard/projects', icon: Folder }, - { name: 'Invoices', href: '/dashboard/invoices', icon: FileText }, - { name: 'Payments', href: '/dashboard/payments', icon: Wallet }, - { name: 'QR / NFC Pay', href: '/dashboard/payments/qr', icon: QrCode }, - { name: 'Disputes', href: '/dashboard/disputes', icon: Scale }, -]; - export function Sidebar() { + const t = useTranslations('nav'); + const tNav = useTranslations('dashboard'); const pathname = usePathname(); const router = useRouter(); const [isMobileOpen, setIsMobileOpen] = useState(false); + const navigation = [ + { name: t('dashboard'), href: '/dashboard', icon: LayoutDashboard }, + { name: t('projects'), href: '/dashboard/projects', icon: Folder }, + { name: t('invoices'), href: '/dashboard/invoices', icon: FileText }, + { name: t('payments'), href: '/dashboard/payments', icon: Wallet }, + { name: t('qrPay'), href: '/dashboard/payments/qr', icon: QrCode }, + { name: t('disputes'), href: '/dashboard/disputes', icon: Scale }, + { name: t('bridgeMonitoring'), href: '/dashboard/monitoring/bridges', icon: Activity }, + { name: t('archival'), href: '/dashboard/admin/archival', icon: Archive }, + { name: t('upgradeSafety'), href: '/dashboard/admin/contracts/upgrades', icon: ShieldCheck }, + { name: t('translations'), href: '/dashboard/admin/i18n', icon: Languages }, + ]; + return ( <> {/* Mobile menu button */} @@ -30,7 +36,7 @@ export function Sidebar() { size="icon" onClick={() => setIsMobileOpen(!isMobileOpen)} className="bg-white shadow-lg" - aria-label={isMobileOpen ? 'Close menu' : 'Open menu'} + aria-label={isMobileOpen ? tNav('closeMenu') : tNav('openMenu')} aria-expanded={isMobileOpen} aria-controls="sidebar-navigation" > @@ -44,8 +50,8 @@ export function Sidebar() { role="navigation" aria-label="Main navigation" className={cn( - 'fixed inset-y-0 left-0 z-40 w-64 bg-white border-r border-gray-200 transform transition-transform duration-200 ease-in-out lg:translate-x-0', - isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0' + 'fixed inset-y-0 start-0 z-40 w-64 bg-white border-e border-gray-200 transform transition-transform duration-200 ease-in-out lg:translate-x-0', + isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0 rtl:translate-x-full rtl:lg:translate-x-0' )} >
diff --git a/frontend/components/markdown/MarkdownContent.tsx b/frontend/components/markdown/MarkdownContent.tsx index ab0db372..4383ae63 100644 --- a/frontend/components/markdown/MarkdownContent.tsx +++ b/frontend/components/markdown/MarkdownContent.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import type { Components } from 'react-markdown'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; @@ -21,7 +22,6 @@ const sanitizeSchema = { export interface MarkdownContentProps { content: string; className?: string; - /** Show Edit / Preview toggle */ previewMode?: boolean; } @@ -32,9 +32,10 @@ export function MarkdownContent({ }: MarkdownContentProps) { const [showPreview, setShowPreview] = useState(true); - const components = useMemo( + const components = useMemo( () => ({ - code({ className: codeClassName, children, ...props }: { className?: string; children?: ReactNode }) { + code(props) { + const { className: codeClassName, children } = props; const match = /language-(\w+)/.exec(codeClassName ?? ''); const code = String(children).replace(/\n$/, ''); if (match) { @@ -46,26 +47,35 @@ export function MarkdownContent({ customStyle={{ margin: 0, borderRadius: '0.375rem', fontSize: '0.8125rem' }} > {code} - SyntaxHighlighter> + ); } return ( - + {children} ); }, - a({ href, children, ...props }: { href?: string; children?: ReactNode }) { - const safe = href?.startsWith('http://') || href?.startsWith('https://') || href?.startsWith('/'); + a(props) { + const { href, children } = props; + const safe = + href?.startsWith('http://') || + href?.startsWith('https://') || + href?.startsWith('/'); if (!safe) return {children}; return ( - + {children} ); }, }), - [] + [], ); return ( diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx new file mode 100644 index 00000000..b8d7e381 --- /dev/null +++ b/frontend/components/ui/table.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx new file mode 100644 index 00000000..b8c96e36 --- /dev/null +++ b/frontend/components/ui/textarea.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => { + return ( +