From 967555b3146bb9f715451c921a5b2b70af909317 Mon Sep 17 00:00:00 2001 From: broda-spendy Date: Wed, 27 May 2026 07:16:56 +0100 Subject: [PATCH] Implement real Soroban RPC integration in vaultEndpoints (Issue #438) - Install @stellar/stellar-sdk package - Create sorobanClient.ts with real contract invocation logic - Replace submitSorobanTx stub with real Soroban transaction submission - Add proper error handling for Soroban simulation failures (502 responses) - Load signing keypair from STELLAR_SECRET_KEY environment variable - Maintain circuit breaker and trace span wrappers around RPC calls - Add comprehensive logging for transaction lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/.env.example | 64 ++++++- backend/package.json | 44 +++-- backend/src/__tests__/api.test.ts | 122 ++++++++----- backend/src/__tests__/setup.ts | 47 ++++- backend/src/sorobanClient.ts | 226 +++++++++++++++++++++++ backend/src/vaultEndpoints.ts | 292 ++++++++++++++++++++++++++++++ 6 files changed, 736 insertions(+), 59 deletions(-) create mode 100644 backend/src/sorobanClient.ts create mode 100644 backend/src/vaultEndpoints.ts diff --git a/backend/.env.example b/backend/.env.example index 99f6c051..59720f26 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,13 +19,71 @@ STELLAR_RPC_URL=https://soroban-testnet.stellar.org STELLAR_NETWORK=testnet STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +# Stellar Signing Key (for submitting Soroban transactions) +# Should be a Stellar secret key (starting with 'S') +STELLAR_SECRET_KEY= + # Vault Configuration VAULT_CONTRACT_ID= -# Optional: Database Configuration (for future use) -# DATABASE_URL= -# DATABASE_POOL_SIZE=10 +# Optional: Database Configuration +DATABASE_URL= +DATABASE_REPLICA_URL= +DATABASE_POOL_SIZE=10 +PRISMA_POOL_MAX=10 +PRISMA_POOL_TIMEOUT_MS=10000 +PRISMA_QUERY_TIMEOUT_MS=5000 + +# Admin audit log persistence mode: memory | prisma | hybrid +ADMIN_AUDIT_LOG_STORAGE=hybrid + +# Prisma runtime connection settings +PRISMA_POOL_SIZE=10 +PRISMA_POOL_TIMEOUT_SEC=10 +PRISMA_QUERY_TIMEOUT_MS=5000 +PRISMA_TX_MAX_WAIT_MS=5000 +PRISMA_TX_TIMEOUT_MS=10000 # Optional: Cache Configuration (for future use) # REDIS_URL=redis://localhost:6379 # CACHE_TTL=300 + +# CORS Configuration +# Comma-separated list of allowed origins. Supports strings or regex like /https?:\/\/.*\.yieldvault\.finance/ +CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.yieldvault.finance + +# Email Notification Configuration +EMAIL_PROVIDER=resend +EMAIL_API_KEY= +EMAIL_FROM_ADDRESS=notifications@yieldvault.finance + +# Latency SLO Monitoring Configuration +# SLO thresholds in milliseconds +SLO_READ_THRESHOLD_MS=200 +SLO_WRITE_THRESHOLD_MS=500 + +# Evaluation window for P95 calculation (5 minutes = 300000 ms) +SLO_EVALUATION_WINDOW_MS=300000 + +# Alert cooldown period to prevent spam (15 minutes = 900000 ms) +SLO_ALERT_COOLDOWN_MS=900000 + +# SLO check interval (1 minute = 60000 ms) +SLO_CHECK_INTERVAL_MS=60000 + +# Alert Integration Configuration +# Set to 'slack', 'pagerduty', or 'both' +ALERT_TYPE=slack + +# Slack Webhook URL (required if ALERT_TYPE includes 'slack') +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK + +# PagerDuty Integration Key (required if ALERT_TYPE includes 'pagerduty') +PAGERDUTY_INTEGRATION_KEY= + +# Event Polling Configuration +# Poll interval for checking new events (10 seconds = 10000 ms) +EVENT_POLL_INTERVAL_MS=10000 + +# Batch size for event replay (number of ledgers per batch) +EVENT_REPLAY_BATCH_SIZE=100 diff --git a/backend/package.json b/backend/package.json index 5bcaf60d..3830751b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,11 @@ "test": "jest", "test:smoke": "npm run build && npm run start &", "lint": "eslint src", - "format": "prettier --write src" + "format": "prettier --write src", + "audit": "npm audit", + "generate:openapi": "tsx scripts/generate-openapi.ts", + "ci:governance": "npm run lint && npm run test && node scripts/check-migrations.js", + "db:check-drift": "prisma migrate diff --from-schema-datamodel prisma/schema.prisma --to-migrations prisma/migrations --exit-code" }, "keywords": [ "stellar", @@ -21,24 +25,42 @@ "author": "", "license": "MIT", "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/instrumentation-express": "^0.66.0", + "@opentelemetry/instrumentation-http": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-node": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.41.1", + "@prisma/client": "^5.10.0", + "@stellar/stellar-sdk": "^12.4.0", + "@types/decimal.js": "^0.0.32", + "cors": "^2.8.6", + "decimal.js": "^10.6.0", + "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.0.0", - "dotenv": "^16.3.1", - "node-cache": "^5.1.2" + "ioredis": "^5.10.1", + "node-cache": "^5.1.2", + "prom-client": "^15.1.3", + "rate-limit-redis": "^4.3.1" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^4.17.17", - "@types/node": "^20.0.0", "@types/jest": "^29.5.0", - "typescript": "^5.1.0", - "tsx": "^4.0.0", - "jest": "^29.5.0", - "ts-jest": "^29.1.0", - "supertest": "^6.3.3", + "@types/node": "^20.0.0", "@types/supertest": "^2.0.12", - "eslint": "^8.40.0", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", - "prettier": "^3.0.0" + "eslint": "^8.40.0", + "fast-check": "^3.23.2", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "prisma": "^5.10.0", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "tsx": "^4.0.0", + "typescript": "^5.1.0" } } diff --git a/backend/src/__tests__/api.test.ts b/backend/src/__tests__/api.test.ts index 41963e7f..d5c27f3d 100644 --- a/backend/src/__tests__/api.test.ts +++ b/backend/src/__tests__/api.test.ts @@ -1,4 +1,22 @@ import request from 'supertest'; + +// Mock the sorobanClient before importing app +jest.mock('../sorobanClient', () => ({ + submitVaultOperation: jest.fn(async (type: string, wallet: string, amount: string, asset: string) => { + // Return a mock transaction hash + return `mock_tx_hash_${type}_${Date.now()}`; + }), + SorobanSimulationError: class SorobanSimulationError extends Error { + code: string; + statusCode: number = 502; + constructor(message: string, code = 'SIMULATION_ERROR') { + super(message); + this.name = 'SorobanSimulationError'; + this.code = code; + } + }, +})); + import app from '../index'; describe('Backend API', () => { @@ -75,7 +93,7 @@ describe('Backend API', () => { describe('Rate Limiting - API Endpoints', () => { it('should include rate limit headers in response', async () => { - const response = await request(app).get('/api/vault/summary'); + const response = await request(app).get('/api/v1/vault/summary'); expect(response.status).toBe(200); expect(response.headers).toHaveProperty('ratelimit-limit'); @@ -88,32 +106,46 @@ describe('Backend API', () => { // It attempts to exceed the API rate limit const requests = Array(35).fill(null); // More than configured limit const results = await Promise.all( - requests.map(() => request(app).get('/api/vault/summary')) + requests.map(() => request(app).get('/api/v1/vault/summary')) ); expect(results.some((r) => r.status === 429)).toBe(true); }); - it('should return 429 with clear error message', async () => { + it('should return 429 with clear error message and Retry-After header', async () => { // Make multiple requests to trigger rate limit const requests = Array(35).fill(null); await Promise.all( - requests.map(() => request(app).get('/api/vault/summary')) + requests.map(() => request(app).get('/api/v1/vault/summary')) ); - const response = await request(app).get('/api/vault/summary'); + const response = await request(app).get('/api/v1/vault/summary'); if (response.status === 429) { expect(response.body).toHaveProperty('error'); expect(response.body).toHaveProperty('status', 429); expect(response.body).toHaveProperty('message'); + // Issue #251: retryAfter field in body + expect(response.body).toHaveProperty('retryAfter'); + expect(typeof response.body.retryAfter).toBe('number'); + // Issue #251: Retry-After header must be present + expect(response.headers).toHaveProperty('retry-after'); } }); - it('should support per-user rate limiting with API key', async () => { - // Test that API key in header is used for rate limiting + it('should support per-user rate limiting with wallet address header', async () => { + // Test that x-wallet-address header is used as the rate-limit key const response = await request(app) - .get('/api/vault/summary') + .get('/api/v1/vault/summary') + .set('x-wallet-address', 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567'); + + expect([200, 429]).toContain(response.status); + }); + + it('should support per-user rate limiting with API key (backward compat)', async () => { + // Test that x-api-key header is still accepted as fallback key + const response = await request(app) + .get('/api/v1/vault/summary') .set('x-api-key', 'test-key-123'); expect([200, 429]).toContain(response.status); @@ -143,48 +175,54 @@ describe('Backend API', () => { }); }); - // ─── Configuration Tests ───────────────────────────────────────────────── + describe('Cache Middleware', () => { + it('should cache repeated list endpoint requests and mark hits', async () => { + const first = await request(app).get('/api/v1/transactions'); + expect(first.headers['x-cache-hit']).toBe('false'); - describe('Configuration', () => { - it('should have proper rate limit defaults', async () => { - // This verifies the backend is configured with sensible defaults - expect(process.env.PORT || 3000).toBeDefined(); + const second = await request(app).get('/api/v1/transactions'); + expect(second.headers['x-cache-hit']).toBe('true'); }); - it('should not expose sensitive info in error responses', async () => { - const response = await request(app).get('/api/vault/summary'); + it('should separate cache entries by query string', async () => { + const first = await request(app).get('/api/v1/transactions?limit=1'); + expect(first.headers['x-cache-hit']).toBe('false'); - // Ensure no stack traces in error responses in production-like environment - if (response.status >= 500) { - if (process.env.NODE_ENV === 'production') { - expect(response.body.message).not.toContain('at '); - expect(response.body.message).not.toContain('Error'); - } - } + const second = await request(app).get('/api/v1/transactions?limit=2'); + expect(second.headers['x-cache-hit']).toBe('false'); + + const third = await request(app).get('/api/v1/transactions?limit=2'); + expect(third.headers['x-cache-hit']).toBe('true'); }); - }); - // ─── Integration Tests ────────────────────────────────────────────────── + it('should invalidate cached list responses after a vault deposit', async () => { + const priorAllowlist = process.env.ALLOWLIST_ENABLED; + process.env.ALLOWLIST_ENABLED = 'false'; - describe('Integration', () => { - it('should have proper CORS headers configured', async () => { - const response = await request(app) - .get('/health') - .set('Origin', 'http://localhost:5173'); + try { + await request(app).get('/api/v1/transactions'); + const cached = await request(app).get('/api/v1/transactions'); + expect(cached.headers['x-cache-hit']).toBe('true'); - // Response should include appropriate headers - expect(response.status).toBe(200); - }); + const depositResponse = await request(app) + .post('/api/v1/vault/deposits') + .send({ + amount: '100', + asset: 'USDC', + walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + email: 'user@example.com', + }); - it('should handle JSON body parsing', async () => { - const response = await request(app) - .post('/api/vault/summary') - .send({ - test: 'data', - }); + expect(depositResponse.status).toBe(201); - // Should either accept or reject with proper error - expect([200, 405, 404, 400]).toContain(response.status); + const afterInvalidate = await request(app).get('/api/v1/transactions'); + expect(afterInvalidate.headers['x-cache-hit']).toBe('false'); + } finally { + process.env.ALLOWLIST_ENABLED = priorAllowlist; + } }); - }); -}); + + it('should cache referral stats for a referrer wallet', async () => { + const referrerWallet = 'GREFERRER1234567890'; + + const codeResponse = await request(app).get(`/api/v1/referrals/code/${referrerWallet}`); diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index 35cd101b..200d36b3 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -1,10 +1,51 @@ import dotenv from 'dotenv'; -// Load environment variables for tests +// CRITICAL: Set NODE_ENV first to ensure environment-aware initialization +process.env.NODE_ENV = 'test'; + +// Load environment variables for tests with override +// This must happen before any modules initialize Prisma or tracing dotenv.config({ path: '.env.test', override: true, }); -// Set test environment -process.env.NODE_ENV = 'test'; +// Explicitly disable tracing for all tests +process.env.OTEL_ENABLED = 'false'; + +// Ensure health/readiness checks pass in test mode even without external RPC configuration +process.env.STELLAR_RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; + +// Set test Soroban configuration (required for submitVaultOperation) +// In tests, these won't actually be used for real transactions +process.env.STELLAR_NETWORK_PASSPHRASE = process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015'; +if (!process.env.STELLAR_SECRET_KEY) { + // Use a dummy test keypair (not used in actual tests since they're mocked) + process.env.STELLAR_SECRET_KEY = 'SBZVMB74Z76QZ3ZZY66NIDQCB5X5KZDQK4JQHJCZVDZ5XWFSM7K7HRE'; +} +if (!process.env.VAULT_CONTRACT_ID) { + process.env.VAULT_CONTRACT_ID = 'CBQHNAXSI55GX2WOOVEDW47GHQU2FWYKCFO4XWJWILTZLVNODAVZCXX'; +} + +// Suppress OpenTelemetry spam in test output +process.env.OTEL_LOG_LEVEL = 'error'; + +// CRITICAL: Patch PrismaClient constructor BEFORE any code tries to instantiate it +// This intercepts the instrumentation hooks and prevents the panic +const PrismaClientModule = require('@prisma/client'); +const OriginalPrismaClient = PrismaClientModule.PrismaClient; + +class PatchedPrismaClient extends OriginalPrismaClient { + constructor(options?: any) { + // Remove any corrupted options that the instrumentation added + const cleanOptions = options || {}; + // Strip out any unrecognized instrumentation-related fields + if (cleanOptions._lib) { + delete cleanOptions._lib; + } + super(cleanOptions); + } +} + +// Replace the exported PrismaClient +PrismaClientModule.PrismaClient = PatchedPrismaClient; diff --git a/backend/src/sorobanClient.ts b/backend/src/sorobanClient.ts new file mode 100644 index 00000000..00c122ce --- /dev/null +++ b/backend/src/sorobanClient.ts @@ -0,0 +1,226 @@ +/** + * Soroban RPC client for submitting vault operations to the Stellar network. + * Uses @stellar/stellar-sdk for contract invocation. + */ + +import { + Keypair, + Contract, + SorobanRpc, + nativeToScVal, + StrKey, + TransactionBuilder, + BASE_FEE, +} from '@stellar/stellar-sdk'; +import { logger } from './middleware/structuredLogging'; +import { getCurrentTraceId } from './tracing'; + +// Initialize Soroban RPC client +const getRpcClient = () => { + const rpcUrl = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org'; + return new SorobanRpc.Server(rpcUrl); +}; + +// Validate that required environment variables are set +function validateEnvironment(): void { + if (!process.env.STELLAR_SECRET_KEY) { + throw new Error('STELLAR_SECRET_KEY environment variable is not set'); + } + if (!process.env.VAULT_CONTRACT_ID) { + throw new Error('VAULT_CONTRACT_ID environment variable is not set'); + } + if (!process.env.STELLAR_NETWORK_PASSPHRASE) { + throw new Error('STELLAR_NETWORK_PASSPHRASE environment variable is not set'); + } +} + +// Get or validate keypair +let cachedKeypair: Keypair | null = null; +function getSourceKeypair(): Keypair { + if (!cachedKeypair) { + try { + cachedKeypair = Keypair.fromSecret(process.env.STELLAR_SECRET_KEY!); + } catch (err) { + throw new Error( + `Invalid STELLAR_SECRET_KEY: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + return cachedKeypair; +} + +export interface SorobanTxError extends Error { + code?: string; + statusCode?: number; +} + +export class SorobanSimulationError extends Error implements SorobanTxError { + public code: string; + public statusCode: number = 502; + + constructor(message: string, code: string = 'SIMULATION_ERROR') { + super(message); + this.name = 'SorobanSimulationError'; + this.code = code; + } +} + +/** + * Submit a Soroban contract invocation to the Stellar network. + * Supports 'deposit' and 'withdrawal' operations on the vault contract. + * + * @param operationType - 'deposit' or 'withdrawal' + * @param walletAddress - The Stellar wallet address making the operation + * @param amount - The amount to deposit/withdraw + * @param asset - The asset code (e.g., 'USDC') + * @returns The transaction hash of the submitted transaction + * @throws SorobanSimulationError if simulation or submission fails + */ +export async function submitVaultOperation( + operationType: 'deposit' | 'withdrawal', + walletAddress: string, + amount: string, + asset: string, +): Promise { + try { + validateEnvironment(); + + const rpc = getRpcClient(); + const sourceKeypair = getSourceKeypair(); + const contractId = process.env.VAULT_CONTRACT_ID!; + const networkPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE!; + + // Validate Stellar address format + if (!StrKey.isValidEd25519PublicKey(walletAddress)) { + throw new Error(`Invalid Stellar wallet address: ${walletAddress}`); + } + + logger.log('debug', `Submitting Soroban ${operationType}`, { + walletAddress, + amount, + asset, + contractId, + traceId: getCurrentTraceId(), + }); + + // Get account details for building the transaction + const sourceAccount = await rpc.getAccount(sourceKeypair.publicKey()); + + // Create contract instance + const contract = new Contract(contractId); + + // Build contract invocation based on operation type + let method: string; + if (operationType === 'deposit') { + method = 'deposit'; + } else if (operationType === 'withdrawal') { + method = 'withdrawal'; + } else { + throw new Error(`Unsupported operation type: ${operationType}`); + } + + // Build the contract invocation operation + const op = contract.call( + method, + nativeToScVal(walletAddress, { type: 'address' }), + nativeToScVal(amount, { type: 'i128' }), + nativeToScVal(asset, { type: 'string' }), + ); + + // Create transaction + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(300) // 5 minute timeout + .build(); + + // Simulate the transaction to validate it and get resource requirements + logger.log('debug', `Simulating Soroban transaction for ${operationType}`, { + traceId: getCurrentTraceId(), + }); + + const simulated = await rpc.simulateTransaction(tx); + + if (SorobanRpc.isSimulationError(simulated)) { + const errorMessage = `Soroban simulation error: ${ + simulated.error || 'Unknown error' + }`; + logger.log('error', errorMessage, { + operationType, + walletAddress, + traceId: getCurrentTraceId(), + }); + throw new SorobanSimulationError(errorMessage, 'SIMULATION_ERROR'); + } + + if (SorobanRpc.isSimulationRestore(simulated)) { + logger.log('warn', 'Soroban transaction requires restore', { + operationType, + walletAddress, + traceId: getCurrentTraceId(), + }); + throw new SorobanSimulationError( + 'Contract state requires restore. Please try again later.', + 'RESTORE_REQUIRED' + ); + } + + // Assemble and submit the transaction + const prepared = SorobanRpc.assembleTransaction(tx, simulated).build(); + + logger.log('debug', `Submitting Soroban transaction for ${operationType}`, { + traceId: getCurrentTraceId(), + }); + + const txResponse = await rpc.sendTransaction(prepared); + + if (txResponse.status === 'FAILED') { + const resultXdr = txResponse.resultXdr; + const errorMessage = `Soroban transaction submission failed: ${resultXdr || 'Unknown error'}`; + logger.log('error', errorMessage, { + operationType, + walletAddress, + traceId: getCurrentTraceId(), + }); + throw new SorobanSimulationError(errorMessage, 'SUBMISSION_FAILED'); + } + + if (txResponse.status === 'ERROR') { + const errorMessage = `Soroban RPC error: ${txResponse.errorResultXdr || 'Unknown error'}`; + logger.log('error', errorMessage, { + operationType, + walletAddress, + traceId: getCurrentTraceId(), + }); + throw new SorobanSimulationError(errorMessage, 'RPC_ERROR'); + } + + // Transaction successfully submitted; return the hash + const transactionHash = txResponse.hash; + logger.log('info', `Soroban ${operationType} submitted successfully`, { + transactionHash, + walletAddress, + traceId: getCurrentTraceId(), + }); + + return transactionHash; + } catch (err) { + if (err instanceof SorobanSimulationError) { + throw err; + } + + const message = err instanceof Error ? err.message : String(err); + logger.log('error', `Unexpected error in submitVaultOperation: ${message}`, { + operationType, + walletAddress, + traceId: getCurrentTraceId(), + }); + + throw new SorobanSimulationError( + `Unexpected error: ${message}`, + 'INTERNAL_ERROR' + ); + } +} diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts new file mode 100644 index 00000000..74407cfc --- /dev/null +++ b/backend/src/vaultEndpoints.ts @@ -0,0 +1,292 @@ +import { Router, Request, Response } from 'express'; +import { emailService } from './emailService'; +import { logger } from './middleware/structuredLogging'; +import { allowlistMiddleware } from './middleware/allowlist'; +import { invalidateCache } from './middleware/cache'; +import { idempotencyStore, IdempotencyConflictError } from './idempotency'; +import { sorobanCircuitBreaker, CircuitOpenError } from './circuitBreaker'; +import { withSpan, getCurrentTraceId } from './tracing'; +import { requireFlag } from './featureFlags'; +import { referralService } from './referralService'; +import { getPrismaClient } from './prismaClient'; +import { emitTransactionEvent, TransactionEventType } from './webhookDelivery'; +import { submitVaultOperation, SorobanSimulationError } from './sorobanClient'; +import crypto from 'crypto'; + +const router = Router(); + +function generateFingerprint(body: any): string { + return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); +} + +/** + * Submits a Soroban RPC call wrapped in the circuit breaker and a trace span. + * Invokes the vault contract to deposit or withdraw funds. + */ +async function submitSorobanTx(type: string, payload: Record): Promise { + return sorobanCircuitBreaker.execute(() => + withSpan('soroban.rpc.submit', async (span) => { + const walletAddress = String(payload.walletAddress ?? ''); + const amount = String(payload.amount ?? ''); + const asset = String(payload.asset ?? ''); + + span.setAttributes({ + 'rpc.type': type, + 'rpc.wallet': walletAddress, + 'rpc.amount': amount, + 'rpc.asset': asset, + }); + + try { + // Call the real Soroban contract + const txHash = await submitVaultOperation( + type as 'deposit' | 'withdrawal', + walletAddress, + amount, + asset, + ); + return txHash; + } catch (err) { + if (err instanceof SorobanSimulationError) { + span.recordException(err); + throw err; + } + throw err; + } + }), + ); +} + +/** Shared handler logic for deposit / withdrawal to avoid duplication. */ +async function handleVaultOperation( + req: Request, + res: Response, + type: 'deposit' | 'withdrawal', +): Promise { + // Task 3: read Idempotency-Key header (spec-compliant name) + const idempotencyKey = + (req.headers['idempotency-key'] as string | undefined) || + (req.headers['x-idempotency-key'] as string | undefined); + + const { amount, asset, walletAddress, email, referralCode } = req.body; + + if (!amount || !asset || !walletAddress) { + return res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Missing required fields: amount, asset, and walletAddress are required', + }); + } + + const operation = async () => { + return withSpan(`vault.${type}`, async (span) => { + span.setAttributes({ + 'vault.amount': String(amount), + 'vault.asset': String(asset), + 'vault.wallet': String(walletAddress), + }); + + let txHash: string; + try { + txHash = await submitSorobanTx(type, { amount, asset, walletAddress }); + } catch (err) { + if (err instanceof CircuitOpenError) { + // Bubble up so the route handler can return 503 + throw err; + } + throw err; + } + + // Persist transaction to DB + const prisma = getPrismaClient(); + await prisma.transaction.create({ + data: { + user: walletAddress, + amount: String(amount), + type, + referralCode, + }, + }); + + // Handle referral recording on deposit + if (type === 'deposit') { + await referralService.recordDeposit(walletAddress, referralCode); + } + + const body = { + id: `tx-${crypto.randomBytes(4).toString('hex')}`, + type, + amount, + asset, + walletAddress, + transactionHash: txHash, + status: 'pending', + timestamp: new Date().toISOString(), + }; + + // Fire webhook delivery in background so transaction API latency is not blocked. + const eventType: TransactionEventType = + type === 'deposit' ? 'transaction.deposit.created' : 'transaction.withdrawal.created'; + void emitTransactionEvent(eventType, { + transactionId: body.id, + amount: String(body.amount), + asset: String(body.asset), + walletAddress: String(body.walletAddress), + transactionHash: String(body.transactionHash), + status: String(body.status), + timestamp: String(body.timestamp), + }); + + span.setAttributes({ 'vault.txHash': txHash }); + + // Post-confirmation email (fire-and-forget) + const schedulePostConfirmation = process.env.NODE_ENV === 'test' + ? (fn: () => Promise) => { + void fn(); + } + : (fn: () => Promise) => { + setTimeout(() => { + void fn(); + }, 100); + }; + + schedulePostConfirmation(async () => { + try { + const confirmationDelayMs = process.env.NODE_ENV === 'test' ? 0 : 5000; + if (confirmationDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, confirmationDelayMs)); + } + logger.log('info', `${type} confirmed on-chain`, { + txHash, + walletAddress, + traceId: getCurrentTraceId(), + }); + if (email) { + const sendFn = + type === 'deposit' + ? emailService.sendDepositConfirmation.bind(emailService) + : emailService.sendWithdrawalConfirmation.bind(emailService); + await sendFn(email, { + amount: String(amount), + asset, + date: new Date().toISOString(), + txHash, + walletAddress, + }); + } + } catch (error) { + logger.log('error', 'Error in post-confirmation email logic', { + error: error instanceof Error ? error.message : String(error), + txHash, + traceId: getCurrentTraceId(), + }); + } + }); + + return { statusCode: 201, body }; + }); + }; + + try { + const invalidateReadCaches = async () => invalidateCache(); + + if (idempotencyKey) { + const fingerprint = generateFingerprint(req.body); + const { result, replayed } = await idempotencyStore.execute( + idempotencyKey, + fingerprint, + operation, + ); + if (replayed) res.setHeader('idempotency-status', 'replayed'); + await invalidateReadCaches(); + return res.status(result.statusCode).json(result.body); + } + + const result = await operation(); + await invalidateReadCaches(); + return res.status(result.statusCode).json(result.body); + } catch (err) { + if (err instanceof IdempotencyConflictError) { + return res.status(409).json({ + error: 'Conflict', + status: 409, + message: err.message, + }); + } + + if (err instanceof SorobanSimulationError) { + // Surface Soroban simulation/submission errors as 502 Bad Gateway + logger.log('error', `Soroban simulation error for ${type}`, { + error: err.message, + code: err.code, + traceId: getCurrentTraceId(), + }); + return res.status(502).json({ + error: 'Bad Gateway', + status: 502, + message: `Soroban transaction failed: ${err.message}`, + code: err.code, + }); + } + + if (err instanceof CircuitOpenError) { + const retryAfterSec = Math.ceil(err.retryAfterMs / 1000); + res.setHeader('Retry-After', String(retryAfterSec)); + return res.status(503).json({ + error: 'Service Unavailable', + status: 503, + message: 'Soroban RPC is temporarily unavailable. Please retry later.', + retryAfterMs: err.retryAfterMs, + }); + } + + logger.log('error', `${type} operation failed`, { + error: err instanceof Error ? err.message : String(err), + traceId: getCurrentTraceId(), + }); + return res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: `Failed to process ${type}`, + }); + } +} + +/** + * POST /api/v1/vault/deposits + * Accepts optional Idempotency-Key header for deduplication. + * Requires wallet address to be on the private beta allowlist (Issue #375). + */ +router.post('/deposits', allowlistMiddleware, (req: Request, res: Response) => + handleVaultOperation(req, res, 'deposit'), +); + +/** + * POST /api/v1/vault/withdrawals + * Accepts optional Idempotency-Key header for deduplication. + * Requires wallet address to be on the private beta allowlist (Issue #375). + */ +router.post('/withdrawals', allowlistMiddleware, (req: Request, res: Response) => + handleVaultOperation(req, res, 'withdrawal'), +); + +// ─── Feature-flagged v2 endpoints ──────────────────────────────────────────── + +/** + * POST /api/v1/vault/deposits/v2 + * Gated behind the "deposit-v2" feature flag. + * Supports per-wallet targeting via x-wallet-address header or body.walletAddress. + */ +router.post('/deposits/v2', requireFlag('deposit-v2'), (req: Request, res: Response) => + handleVaultOperation(req, res, 'deposit'), +); + +/** + * POST /api/v1/vault/strategy + * Gated behind the "strategy-selection" feature flag. + */ +router.post('/strategy', requireFlag('strategy-selection'), (_req: Request, res: Response) => { + res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); +}); + +export default router;