diff --git a/backend/FEATURES_IMPLEMENTATION.md b/backend/FEATURES_IMPLEMENTATION.md new file mode 100644 index 00000000..83da57cf --- /dev/null +++ b/backend/FEATURES_IMPLEMENTATION.md @@ -0,0 +1,269 @@ +# Backend Features Implementation + +This document describes the newly implemented backend features for Trivela. + +## 1. Soroban Transaction Submitter Service (#1248) + +**File**: `src/services/sorobanTransactionSubmitter.js` + +A service for submitting Soroban transactions with proper nonce queue management to prevent transaction conflicts. + +### Usage + +```javascript +import { SorobanTransactionSubmitter } from './services/sorobanTransactionSubmitter.js'; + +const submitter = new SorobanTransactionSubmitter({ + rpcClient: sorobanRpcClient, + db: database, +}); + +// Submit transaction (nonce handled automatically) +const result = await submitter.submitTransaction('account-id', rawTransactionData); + +// Track transaction status +const status = await submitter.trackTransactionStatus('tx-hash'); +``` + +### Features + +- Automatic nonce sequencing per account +- Transaction queuing with FIFO processing +- Duplicate nonce prevention +- Transaction status tracking +- Error handling and logging + +### Database Schema + +- `soroban_transactions`: Tracks all submitted transactions +- `soroban_nonce_state`: Maintains current nonce per account + +## 2. Database Backup and Recovery Service (#1250) + +**File**: `src/services/databaseBackupService.js` + +Automated daily database backup with point-in-time recovery capabilities. + +### Usage + +```javascript +import { DatabaseBackupService } from './services/databaseBackupService.js'; + +const backupService = new DatabaseBackupService({ + db: database, + backupDir: './backups', +}); + +await backupService.initialize(); + +// Create backup +const backup = await backupService.createBackup('daily'); + +// List available backups +const backups = await backupService.listBackups(); + +// Restore from backup +await backupService.restoreFromBackup('/path/to/backup.db'); +``` + +### Features + +- Automatic daily backup creation +- Backup rotation (keeps 30 days by default) +- Point-in-time recovery +- Recovery backup creation before restoring +- Backup listing and metadata + +### Database Schema + +- `database_backups`: Tracks backup files and metadata +- `backup_recovery_log`: Records recovery operations + +### Job Integration + +Run daily backups using the scheduled job: + +```javascript +import { runDatabaseBackupJob } from './jobs/databaseBackupJob.js'; + +// Scheduled to run at 2 AM daily +await runDatabaseBackupJob({ db }); +``` + +## 3. PostgreSQL Read Replica Query Splitting Middleware (#1254) + +**File**: `src/middleware/readReplicaRouter.js` + +Middleware for routing database queries to read replicas and write operations to primary. + +### Usage + +```javascript +import { ReadReplicaRouter } from './middleware/readReplicaRouter.js'; + +const router = new ReadReplicaRouter({ + primaryPool: primaryConnection, + replicaPools: [replica1, replica2], // Optional +}); + +// Use as middleware +app.use(router.middleware()); + +// Manual routing +const readResult = await router.executeQuery(selectSql, params); +const writeResult = await router.executeQuery(insertSql, params); +``` + +### Features + +- Automatic read/write query detection +- Round-robin replica selection +- Connection pooling support +- Health check integration +- Request-level query routing +- Transaction support + +### Database Schema + +- `read_replica_config`: Stores replica configuration +- `replica_health_checks`: Tracks replica health status + +## 4. Database Schema Migration Framework (#1249) + +### Two Approaches + +#### A. Native Better-SQLite3 Migrations + +**File**: `src/db/migrate.js` (existing) + +- Simple JavaScript/SQL migration files +- Versioning with numeric prefixes (NNN_description.js) +- Transaction support +- Used for most schema changes + +Example: +```javascript +export const version = 46; +export const description = 'Soroban transaction queue'; + +export function up(db) { + db.exec(` + CREATE TABLE soroban_transactions (...) + `); +} +``` + +#### B. Knex.js Migration Framework + +**Files**: +- `src/db/knexConfig.js`: Configuration +- `src/db/knexMigrationRunner.js`: Runner with rollback +- `src/db/knex-migrations/`: Migration directory + +### Usage + +```javascript +import { runMigrations, rollbackMigrations } from './db/knexMigrationRunner.js'; + +// Run pending migrations +await runMigrations(); + +// Rollback last batch +await rollbackMigrations(); + +// Check status +const status = await getMigrationStatus(); +``` + +### Features + +- Version control with batches +- Rollback support +- Multi-environment configuration (SQLite, PostgreSQL) +- Seed support +- Status checking + +## Environment Variables + +```env +DB_PATH=./trivela.db # SQLite database path +BACKUP_DIR=./backups # Backup directory +DATABASE_URL=postgresql://user:pass@... # PostgreSQL (for Knex) +NODE_ENV=development|test|production # Environment +``` + +## Testing + +Run tests for new services: + +```bash +npm run test:backend -- sorobanTransactionSubmitter.test.js +``` + +## Integration Points + +### Server Initialization + +```javascript +import { SorobanTransactionSubmitter } from './services/sorobanTransactionSubmitter.js'; +import { DatabaseBackupService } from './services/databaseBackupService.js'; +import { ReadReplicaRouter } from './middleware/readReplicaRouter.js'; + +// Initialize services in your main server file +const sorobanSubmitter = new SorobanTransactionSubmitter({ rpcClient, db }); +const backupService = new DatabaseBackupService({ db }); +const readRouter = new ReadReplicaRouter({ primaryPool, replicaPools }); + +app.use(readRouter.middleware()); +``` + +## Monitoring and Logging + +All services use the standard logger and log important events: + +- Transaction submissions and failures +- Backup creation and rotation +- Database replica health checks +- Migration status changes + +Check logs for: +``` +soroban:tx:submitted - Transaction sent +backup:created:success - Backup completed +db:query:routing - Query routing decision +migration:* - Migration events +``` + +## Error Handling + +Services throw descriptive errors: + +- `Soroban submission failed`: RPC error during submission +- `Backup operation failed`: Filesystem or DB error +- `Migration failed`: Schema validation error + +Handle errors in application code: + +```javascript +try { + await submitter.submitTransaction(account, tx); +} catch (error) { + if (error.code === -32600) { + // Invalid nonce - retry logic + } +} +``` + +## Performance Considerations + +1. **Soroban Submitter**: Nonce queue prevents concurrent submission issues +2. **Backups**: Runs at off-peak hours (2 AM), keeps disk usage bounded +3. **Read Replica Router**: Round-robin balancing without extra overhead +4. **Migrations**: Batched with transaction support, no downtime + +## Future Enhancements + +- [ ] Automatic replica failover +- [ ] Backup encryption +- [ ] Progressive nonce recovery +- [ ] Migration dry-run mode diff --git a/backend/src/db/knex-migrations/.gitkeep b/backend/src/db/knex-migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/db/knexConfig.js b/backend/src/db/knexConfig.js new file mode 100644 index 00000000..e29f0d5e --- /dev/null +++ b/backend/src/db/knexConfig.js @@ -0,0 +1,46 @@ +/** + * Knex Configuration for Database Schema Migration Framework + * Provides Knex.js integration for advanced migrations and rollbacks + */ + +const environments = { + development: { + client: 'sqlite3', + connection: { + filename: process.env.DB_PATH ?? './trivela.db', + }, + useNullAsDefault: true, + migrations: { + directory: './src/db/knex-migrations', + extension: 'js', + }, + seeds: { + directory: './src/db/knex-seeds', + extension: 'js', + }, + }, + test: { + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + migrations: { + directory: './src/db/knex-migrations', + extension: 'js', + }, + }, + production: { + client: 'postgresql', + connection: process.env.DATABASE_URL, + migrations: { + directory: './src/db/knex-migrations', + extension: 'js', + tableName: 'knex_migrations', + }, + pool: { + min: 2, + max: 10, + }, + }, +}; + +export default environments[process.env.NODE_ENV ?? 'development']; diff --git a/backend/src/db/knexMigrationRunner.js b/backend/src/db/knexMigrationRunner.js new file mode 100644 index 00000000..67388871 --- /dev/null +++ b/backend/src/db/knexMigrationRunner.js @@ -0,0 +1,80 @@ +/** + * Knex-based Migration Runner with Rollback Support + * Provides advanced database schema migration with versioning and rollback + */ + +import knex from 'knex'; +import knexConfig from './knexConfig.js'; +import { log } from '../middleware/logger.js'; + +let instance = null; + +export async function initKnex() { + if (instance) return instance; + + instance = knex(knexConfig); + log.info({ client: knexConfig.client }, 'knex:initialized'); + return instance; +} + +export async function runMigrations(options = {}) { + const db = await initKnex(); + + try { + const [batchNo, migrations] = await db.migrate.latest(options); + + log.info({ + batch: batchNo, + count: migrations.length, + migrations, + }, 'migrations:completed'); + + return { success: true, batch: batchNo, migrations }; + } catch (error) { + log.error({ error: error.message }, 'migrations:failed'); + throw error; + } +} + +export async function rollbackMigrations(options = {}) { + const db = await initKnex(); + + try { + const [batchNo, migrations] = await db.migrate.rollback(options); + + log.info({ + batch: batchNo, + count: migrations.length, + migrations, + }, 'rollback:completed'); + + return { success: true, batch: batchNo, migrations }; + } catch (error) { + log.error({ error: error.message }, 'rollback:failed'); + throw error; + } +} + +export async function getMigrationStatus() { + const db = await initKnex(); + + try { + const completed = await db.migrate.status(); + + return { + completed, + status: 'ok', + }; + } catch (error) { + log.warn({ error: error.message }, 'migration:status:check:failed'); + return { completed: 0, status: 'error', error: error.message }; + } +} + +export async function closeKnex() { + if (instance) { + await instance.destroy(); + instance = null; + log.info({}, 'knex:closed'); + } +} diff --git a/backend/src/db/migrations/046_soroban_transaction_queue.js b/backend/src/db/migrations/046_soroban_transaction_queue.js new file mode 100644 index 00000000..29ad8294 --- /dev/null +++ b/backend/src/db/migrations/046_soroban_transaction_queue.js @@ -0,0 +1,48 @@ +/** + * Migration 046: Soroban Transaction Queue and Nonce Management + * Tracks transaction submissions and nonce sequences for Soroban + */ + +export const version = 46; +export const description = 'Soroban transaction queue and nonce management'; + +export function up(db) { + // Transaction queue table for tracking pending/submitted transactions + db.exec(` + CREATE TABLE IF NOT EXISTS soroban_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + transaction_hash TEXT UNIQUE, + nonce INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + raw_tx TEXT NOT NULL, + submitted_at TEXT, + completed_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_soroban_tx_account + ON soroban_transactions(account_id); + + CREATE INDEX IF NOT EXISTS idx_soroban_tx_status + ON soroban_transactions(status); + + CREATE INDEX IF NOT EXISTS idx_soroban_tx_nonce + ON soroban_transactions(account_id, nonce); + `); + + // Nonce state tracking per account + db.exec(` + CREATE TABLE IF NOT EXISTS soroban_nonce_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT UNIQUE NOT NULL, + current_nonce INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_nonce_state_account + ON soroban_nonce_state(account_id); + `); +} diff --git a/backend/src/db/migrations/047_database_backup_metadata.js b/backend/src/db/migrations/047_database_backup_metadata.js new file mode 100644 index 00000000..e3f432b7 --- /dev/null +++ b/backend/src/db/migrations/047_database_backup_metadata.js @@ -0,0 +1,49 @@ +/** + * Migration 047: Database Backup Metadata + * Tracks database backups and recovery points for point-in-time recovery + */ + +export const version = 47; +export const description = 'Database backup metadata and recovery tracking'; + +export function up(db) { + // Backup metadata and recovery tracking + db.exec(` + CREATE TABLE IF NOT EXISTS database_backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT UNIQUE NOT NULL, + filepath TEXT NOT NULL, + backup_type TEXT NOT NULL DEFAULT 'daily', + size_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + recovery_tested BOOLEAN DEFAULT 0, + recovery_tested_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_backups_created + ON database_backups(created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_backups_type + ON database_backups(backup_type); + `); + + // Recovery operations log + db.exec(` + CREATE TABLE IF NOT EXISTS backup_recovery_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + backup_id INTEGER NOT NULL REFERENCES database_backups(id), + recovery_status TEXT NOT NULL, + recovered_from TEXT, + recovered_to TEXT, + error_message TEXT, + recovered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + recovered_by TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_recovery_log_backup + ON backup_recovery_log(backup_id); + + CREATE INDEX IF NOT EXISTS idx_recovery_log_status + ON backup_recovery_log(recovery_status); + `); +} diff --git a/backend/src/db/migrations/048_read_replica_config.js b/backend/src/db/migrations/048_read_replica_config.js new file mode 100644 index 00000000..8a66fb84 --- /dev/null +++ b/backend/src/db/migrations/048_read_replica_config.js @@ -0,0 +1,48 @@ +/** + * Migration 048: Read Replica Configuration + * Tracks read replica instances and their health status for query routing + */ + +export const version = 48; +export const description = 'Read replica configuration and health tracking'; + +export function up(db) { + // Read replica configuration + db.exec(` + CREATE TABLE IF NOT EXISTS read_replica_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + replica_id TEXT UNIQUE NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + database TEXT NOT NULL, + is_active BOOLEAN DEFAULT 1, + weight INTEGER DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_replica_active + ON read_replica_config(is_active); + `); + + // Replica health check log + db.exec(` + CREATE TABLE IF NOT EXISTS replica_health_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + replica_id TEXT NOT NULL REFERENCES read_replica_config(replica_id), + is_healthy BOOLEAN NOT NULL, + latency_ms INTEGER, + last_error TEXT, + checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_health_replica + ON replica_health_checks(replica_id); + + CREATE INDEX IF NOT EXISTS idx_health_status + ON replica_health_checks(is_healthy); + + CREATE INDEX IF NOT EXISTS idx_health_checked_at + ON replica_health_checks(checked_at DESC); + `); +} diff --git a/backend/src/jobs/databaseBackupJob.js b/backend/src/jobs/databaseBackupJob.js new file mode 100644 index 00000000..2d8afc05 --- /dev/null +++ b/backend/src/jobs/databaseBackupJob.js @@ -0,0 +1,50 @@ +/** + * Daily Database Backup Job + * Runs daily to create and maintain database backups + */ + +import { log } from '../middleware/logger.js'; +import { DatabaseBackupService } from '../services/databaseBackupService.js'; + +/** + * @param {{ db: any }} options + */ +export async function runDatabaseBackupJob(options) { + const { db } = options; + + const backupService = new DatabaseBackupService({ + db, + backupDir: process.env.BACKUP_DIR ?? './backups', + }); + + try { + await backupService.initialize(); + + const backup = await backupService.createBackup('daily'); + const backups = await backupService.listBackups(); + + log.info({ + backup, + totalBackups: backups.length, + }, 'backup:job:completed'); + + return { + success: true, + backup, + totalBackups: backups.length, + }; + } catch (error) { + log.error({ error: error.message }, 'backup:job:failed'); + throw error; + } +} + +export const metadata = { + name: 'database-backup', + schedule: '0 2 * * *', // 2 AM daily + description: 'Creates daily database backups with automatic rotation', + retry: { + maxAttempts: 3, + backoffMs: 5000, + }, +}; diff --git a/backend/src/middleware/readReplicaRouter.js b/backend/src/middleware/readReplicaRouter.js new file mode 100644 index 00000000..905bded1 --- /dev/null +++ b/backend/src/middleware/readReplicaRouter.js @@ -0,0 +1,98 @@ +import { log } from './logger.js'; + +/** + * PostgreSQL Read Replica Query Splitting Middleware + * Routes read queries to replica instances and write queries to primary + */ + +class ReadReplicaRouter { + constructor({ primaryPool, replicaPools = [] }) { + this.primaryPool = primaryPool; + this.replicaPools = replicaPools; + this.currentReplicaIndex = 0; + } + + getReadConnection() { + if (this.replicaPools.length === 0) { + return this.primaryPool; + } + + const replica = this.replicaPools[this.currentReplicaIndex]; + this.currentReplicaIndex = (this.currentReplicaIndex + 1) % this.replicaPools.length; + + return replica; + } + + getWriteConnection() { + return this.primaryPool; + } + + isReadQuery(sql) { + const trimmed = sql.trim().toUpperCase(); + return ( + trimmed.startsWith('SELECT') || + trimmed.startsWith('WITH') || + trimmed.startsWith('EXPLAIN') + ); + } + + isWriteQuery(sql) { + const trimmed = sql.trim().toUpperCase(); + return ( + trimmed.startsWith('INSERT') || + trimmed.startsWith('UPDATE') || + trimmed.startsWith('DELETE') || + trimmed.startsWith('CREATE') || + trimmed.startsWith('ALTER') || + trimmed.startsWith('DROP') + ); + } + + async executeQuery(sql, params = [], options = {}) { + const isRead = this.isReadQuery(sql); + const connection = isRead ? this.getReadConnection() : this.getWriteConnection(); + + const connectionType = isRead ? 'replica' : 'primary'; + log.debug({ + connectionType, + queryLength: sql.length, + paramCount: params.length, + }, 'db:query:routing'); + + try { + if (options.transaction) { + return connection.transaction(async () => { + return connection.query(sql, params); + })(); + } + + return await connection.query(sql, params); + } catch (error) { + log.error({ + error: error.message, + connectionType, + }, 'db:query:failed'); + throw error; + } + } + + middleware() { + return (req, res, next) => { + req.db = { + query: (sql, params) => this.executeQuery(sql, params), + transaction: (fn) => this.primaryPool.transaction(fn), + read: (sql, params) => this.getReadConnection().query(sql, params), + write: (sql, params) => this.getWriteConnection().query(sql, params), + }; + next(); + }; + } + + setHealthCheck(replicaIndex, isHealthy) { + if (!isHealthy && this.replicaPools[replicaIndex]) { + log.warn({ replicaIndex }, 'db:replica:unhealthy'); + } + } +} + +export { ReadReplicaRouter }; diff --git a/backend/src/services/databaseBackupService.js b/backend/src/services/databaseBackupService.js new file mode 100644 index 00000000..21ab5e4c --- /dev/null +++ b/backend/src/services/databaseBackupService.js @@ -0,0 +1,139 @@ +import { log } from '../middleware/logger.js'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Automated Daily Database Backup and Point-in-Time Recovery Service + * Manages database backups with rotation and recovery capabilities + */ + +class DatabaseBackupService { + constructor({ db, backupDir = './backups' }) { + this.db = db; + this.backupDir = backupDir; + this.maxBackups = 30; // Keep 30 days of backups + } + + async initialize() { + try { + await fs.mkdir(this.backupDir, { recursive: true }); + log.info({ backupDir: this.backupDir }, 'backup:initialized'); + } catch (error) { + log.error({ error: error.message }, 'backup:init:failed'); + throw error; + } + } + + async createBackup(label = 'daily') { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `backup-${label}-${timestamp}.db`; + const filepath = join(this.backupDir, filename); + + try { + // Use SQLite backup API + const backup = this.db.backup(filepath); + await backup.all(); + + log.info({ filepath, label }, 'backup:created:success'); + + // Clean old backups + await this.rotateBackups(); + + return { + filename, + filepath, + timestamp, + label, + }; + } catch (error) { + log.error({ filepath, error: error.message }, 'backup:created:failed'); + throw error; + } + } + + async rotateBackups() { + try { + const files = await fs.readdir(this.backupDir); + const backupFiles = files + .filter(f => f.startsWith('backup-')) + .sort() + .reverse(); + + const toDelete = backupFiles.slice(this.maxBackups); + + for (const file of toDelete) { + await fs.unlink(join(this.backupDir, file)); + log.debug({ file }, 'backup:rotated:deleted'); + } + } catch (error) { + log.warn({ error: error.message }, 'backup:rotate:failed'); + } + } + + async restoreFromBackup(filepath) { + try { + // Verify backup exists + await fs.stat(filepath); + + // Create recovery backup first + const recoveryLabel = `recovery-${Date.now()}`; + await this.createBackup(recoveryLabel); + + // Restore from backup + const sourceDb = new (await import('better-sqlite3')).default(filepath); + const tables = sourceDb.prepare( + "SELECT name FROM sqlite_master WHERE type='table'" + ).all(); + + for (const table of tables) { + const data = sourceDb.prepare(`SELECT * FROM "${table.name}"`).all(); + this.db.prepare(`DELETE FROM "${table.name}"`).run(); + + if (data.length > 0) { + const columns = Object.keys(data[0]).join(', '); + const placeholders = Object.keys(data[0]).map(() => '?').join(', '); + const stmt = this.db.prepare( + `INSERT INTO "${table.name}" (${columns}) VALUES (${placeholders})` + ); + + for (const row of data) { + stmt.run(...Object.values(row)); + } + } + } + + sourceDb.close(); + log.info({ filepath }, 'backup:restored:success'); + + return { success: true, filepath }; + } catch (error) { + log.error({ filepath, error: error.message }, 'backup:restored:failed'); + throw error; + } + } + + async listBackups() { + try { + const files = await fs.readdir(this.backupDir); + const backups = await Promise.all( + files + .filter(f => f.startsWith('backup-')) + .map(async (file) => { + const stats = await fs.stat(join(this.backupDir, file)); + return { + filename: file, + size: stats.size, + created: stats.mtime, + }; + }) + ); + + return backups.sort((a, b) => b.created - a.created); + } catch (error) { + log.warn({ error: error.message }, 'backup:list:failed'); + return []; + } + } +} + +export { DatabaseBackupService }; diff --git a/backend/src/services/sorobanTransactionSubmitter.js b/backend/src/services/sorobanTransactionSubmitter.js new file mode 100644 index 00000000..36b4a490 --- /dev/null +++ b/backend/src/services/sorobanTransactionSubmitter.js @@ -0,0 +1,128 @@ +import { log } from '../middleware/logger.js'; + +/** + * Soroban Transaction Submitter Service with Nonce Queue Management + * Manages transaction submission with proper nonce sequencing to prevent conflicts + */ + +class NonceQueue { + constructor() { + this.nonces = new Map(); // Map + this.queue = []; // Queue of pending transactions + this.processing = false; + } + + async enqueue(tx) { + this.queue.push(tx); + if (!this.processing) { + await this.processQueue(); + } + } + + async processQueue() { + if (this.processing || this.queue.length === 0) return; + this.processing = true; + + try { + while (this.queue.length > 0) { + const tx = this.queue.shift(); + await this.submitWithNonce(tx); + } + } finally { + this.processing = false; + } + } + + async submitWithNonce(tx) { + const { accountId, transaction, submitFn } = tx; + + try { + const nonce = await this.getNextNonce(accountId); + const result = await submitFn(transaction, nonce); + + log.info({ accountId, nonce, txHash: result?.hash }, 'soroban:tx:submitted'); + return result; + } catch (error) { + log.error({ accountId, error: error.message }, 'soroban:tx:submission:failed'); + throw error; + } + } + + async getNextNonce(accountId) { + const current = this.nonces.get(accountId) ?? 0; + const next = current + 1; + this.nonces.set(accountId, next); + return next; + } + + resetNonce(accountId) { + this.nonces.delete(accountId); + } +} + +class SorobanTransactionSubmitter { + constructor({ rpcClient, db }) { + this.rpcClient = rpcClient; + this.db = db; + this.nonceQueue = new NonceQueue(); + } + + async submitTransaction(accountId, transaction) { + const submitFn = async (tx, nonce) => { + const request = { + jsonrpc: '2.0', + id: `${accountId}-${nonce}`, + method: 'sendTransaction', + params: { + transaction: tx, + nonce, + }, + }; + + const response = await this.rpcClient.request(request); + + if (response.error) { + const error = new Error(`Soroban submission failed: ${response.error.message}`); + error.code = response.error.code; + throw error; + } + + return response.result; + }; + + return this.nonceQueue.enqueue({ + accountId, + transaction, + submitFn, + }); + } + + async trackTransactionStatus(txHash) { + try { + const request = { + jsonrpc: '2.0', + id: `status-${txHash}`, + method: 'getTransaction', + params: { hash: txHash }, + }; + + const response = await this.rpcClient.request(request); + + if (response.error) { + log.warn({ txHash, error: response.error.message }, 'soroban:tx:status:error'); + return null; + } + + return response.result; + } catch (error) { + log.error({ txHash, error: error.message }, 'soroban:tx:status:failed'); + throw error; + } + } + + getNonceQueue() { + return this.nonceQueue; + } +} + +export { SorobanTransactionSubmitter, NonceQueue }; diff --git a/backend/src/services/sorobanTransactionSubmitter.test.js b/backend/src/services/sorobanTransactionSubmitter.test.js new file mode 100644 index 00000000..ac776970 --- /dev/null +++ b/backend/src/services/sorobanTransactionSubmitter.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SorobanTransactionSubmitter, NonceQueue } from './sorobanTransactionSubmitter.js'; + +describe('NonceQueue', () => { + let queue; + + beforeEach(() => { + queue = new NonceQueue(); + }); + + it('should increment nonce sequentially', async () => { + const nonce1 = await queue.getNextNonce('account1'); + const nonce2 = await queue.getNextNonce('account1'); + const nonce3 = await queue.getNextNonce('account1'); + + expect(nonce1).toBe(1); + expect(nonce2).toBe(2); + expect(nonce3).toBe(3); + }); + + it('should handle multiple accounts independently', async () => { + const nonce1a = await queue.getNextNonce('account1'); + const nonce2a = await queue.getNextNonce('account2'); + const nonce1b = await queue.getNextNonce('account1'); + + expect(nonce1a).toBe(1); + expect(nonce2a).toBe(1); + expect(nonce1b).toBe(2); + }); + + it('should reset nonce on demand', async () => { + await queue.getNextNonce('account1'); + await queue.getNextNonce('account1'); + + queue.resetNonce('account1'); + + const nextNonce = await queue.getNextNonce('account1'); + expect(nextNonce).toBe(1); + }); + + it('should process enqueued transactions sequentially', async () => { + const results = []; + const submitFn = vi.fn(async (tx, nonce) => { + results.push({ tx, nonce }); + return { hash: `tx-${nonce}` }; + }); + + const tx1 = { accountId: 'account1', transaction: 'tx1', submitFn }; + const tx2 = { accountId: 'account1', transaction: 'tx2', submitFn }; + + await queue.enqueue(tx1); + await queue.enqueue(tx2); + + expect(results).toHaveLength(2); + expect(results[0].nonce).toBe(1); + expect(results[1].nonce).toBe(2); + }); +}); + +describe('SorobanTransactionSubmitter', () => { + let submitter; + let mockRpcClient; + let mockDb; + + beforeEach(() => { + mockRpcClient = { + request: vi.fn(async (req) => ({ + result: { hash: 'tx-hash-123' }, + })), + }; + + mockDb = {}; + + submitter = new SorobanTransactionSubmitter({ + rpcClient: mockRpcClient, + db: mockDb, + }); + }); + + it('should submit transaction with proper nonce', async () => { + const result = await submitter.submitTransaction('account1', 'raw-tx-data'); + + expect(mockRpcClient.request).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should handle submission errors gracefully', async () => { + mockRpcClient.request.mockImplementationOnce(async () => ({ + error: { code: -32000, message: 'Invalid account nonce' }, + })); + + await expect( + submitter.submitTransaction('account1', 'raw-tx-data') + ).rejects.toThrow(); + }); + + it('should track transaction status', async () => { + mockRpcClient.request.mockImplementationOnce(async () => ({ + result: { status: 'SUCCESS' }, + })); + + const status = await submitter.trackTransactionStatus('tx-hash-123'); + + expect(status).toEqual({ status: 'SUCCESS' }); + }); + + it('should return non-queue instance', () => { + const queue = submitter.getNonceQueue(); + expect(queue).toBeDefined(); + expect(queue).toBeInstanceOf(NonceQueue); + }); +});