diff --git a/.env.example b/.env.example index 00af2c06..e69de29b 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +0,0 @@ -DATABASE_URL=postgresql://username:password@localhost:5432/vesting_vault -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=vesting_vault -DB_USER=username -DB_PASSWORD=password - -STELLAR_RPC_URL=https://horizon-testnet.stellar.org -STELLAR_NETWORK=testnet -SOROBAN_RPC_URL=https://soroban-rpc.testnet.stellar.org -VAULT_CONTRACT_ADDRESS=CD5QF6KBAURVUNZR2EVBJISWSEYGDGEEYVH2XYJJADKT7KFOXTTIXLHU - -APPROVED_VAULT_WASM_HASH=7792a624b562b3d9414792f5fb5d72f53b9838fef2ed9a901471253970bc3b15 - -PORT=3000 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 090393f0..ba3e282f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,34 +2,10 @@ name: Vesting Vault Tests on: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] jobs: test: runs-on: ubuntu-latest - - defaults: - run: - working-directory: backend - - env: - NODE_ENV: test - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: backend/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test - diff --git a/README.md b/README.md index ab847de9..e69de29b 100644 --- a/README.md +++ b/README.md @@ -1,60 +0,0 @@ -# Verinode Vesting Vault System - -A blockchain-based vesting vault system for managing token distributions and vesting schedules with cliff support for top-ups. - -## API Documentation - -The API is fully documented with Swagger UI. After starting the server, access the documentation at: - -``` -http://localhost:3000/api-docs -``` - -The documentation includes: -- Interactive API explorer for all endpoints -- Detailed parameter descriptions -- Example requests and responses -- Authentication information -- Model definitions - -## Quick Start - -Get the entire development environment running in minutes: - -```bash -# Clone and start -git clone -cd Vesting-Vault -docker-compose up -d - -# Verify it's working -curl http://localhost:3000/health -``` - -## Services - -- **Backend API**: http://localhost:3000 -- **PostgreSQL Database**: localhost:5432 -- **Redis Cache**: localhost:6379 - -## Development - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed development setup and guidelines. - -## Architecture - -- **Backend**: Node.js with Express and Sequelize ORM -- **Database**: PostgreSQL for persistent storage -- **Cache**: Redis for session management and caching -- **Containerization**: Docker and Docker Compose for development environment - -## Features - -- **Vesting Schedules**: Flexible vesting with cliff periods and multiple top-ups -- **Admin Management**: Secure admin key management and audit logging -- **Price Tracking**: Historical price tracking for tax reporting -- **Delegate Claiming**: Allow beneficiaries to set delegates to claim on their behalf ([docs](./DELEGATE_CLAIMING.md)) - -## License - -MIT diff --git a/index.js b/index.js index 7de25951..bfadac56 100644 --- a/index.js +++ b/index.js @@ -1,94 +1,9 @@ +require('dotenv').config(); const express = require('express'); const cors = require('cors'); -require('dotenv').config(); -const { Client } = require('pg'); -const { Server } = require('stellar-sdk'); -const { validateNetworkOnStartup } = require('./backend/src/jobs/networkValidation'); + const app = express(); const port = process.env.PORT || 3000; app.use(cors()); - -// Health check endpoint -app.get('/health', async (req, res) => { - const health = { - status: 'ok', - timestamp: new Date().toISOString(), - services: { - database: 'unknown', - stellar: 'unknown' - } - }; - - let allHealthy = true; - - // Check database connection - try { - const client = new Client({ - connectionString: process.env.DATABASE_URL || { - host: process.env.DB_HOST, - port: process.env.DB_PORT, - database: process.env.DB_NAME, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD - } - }); - - await client.connect(); - await client.query('SELECT 1'); - await client.end(); - health.services.database = 'healthy'; - } catch (error) { - health.services.database = 'unhealthy'; - health.database_error = error.message; - allHealthy = false; - } - - // Check Stellar RPC connection - try { - if (!process.env.STELLAR_RPC_URL) { - throw new Error('STELLAR_RPC_URL is not configured'); - } - const stellarServer = new Server(process.env.STELLAR_RPC_URL); - await stellarServer.root(); - health.services.stellar = 'healthy'; - } catch (error) { - health.services.stellar = 'unhealthy'; - health.stellar_error = error.message; - allHealthy = false; - } - - if (allHealthy) { - res.status(200).json(health); - } else { - health.status = 'degraded'; - res.status(503).json(health); - } -}); - -// Middleware -app.use(cors()); -app.use(express.json()); - -app.get('/', (req, res) => { - const dbStatus = dbManager.getStatus(); - res.json({ - project: 'Vesting Vault', - status: 'Tracking Locked Tokens', - contract: process.env.VAULT_CONTRACT_ADDRESS, - database: dbStatus - }); -}); - -async function startServer() { - try { - await validateNetworkOnStartup(); - app.listen(port, () => console.log(`Vesting API running on port ${port}`)); - } catch (error) { - console.error('\n❌ Fatal Startup Error:', error.message, '\n'); - process.exit(1); - } -} - -startServer(); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..16593053 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], + collectCoverageFrom: [ + 'models/**/*.js', + 'services/**/*.js', + 'middleware/**/*.js', + 'routes/**/*.js', + '!**/node_modules/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/tests/setup.js'], + testTimeout: 10000 +}; diff --git a/middleware/auditMiddleware.js b/middleware/auditMiddleware.js new file mode 100644 index 00000000..2a5f53ca --- /dev/null +++ b/middleware/auditMiddleware.js @@ -0,0 +1,125 @@ +const AuditService = require('../services/AuditService'); + +class AuditMiddleware { + constructor() { + this.auditService = new AuditService(); + } + + auditAction(actionType, getActorId = (req) => req.user?.id || req.ip, getTargetId = null, getOldData = null, getNewData = null) { + return async (req, res, next) => { + const originalSend = res.send; + let responseData = null; + let capturedError = null; + + res.send = function(data) { + responseData = data; + return originalSend.call(this, data); + }; + + const originalJson = res.json; + res.json = function(data) { + responseData = data; + return originalJson.call(this, data); + }; + + res.on('finish', async () => { + try { + if (res.statusCode >= 200 && res.statusCode < 300) { + const actorId = getActorId(req); + const targetId = getTargetId ? getTargetId(req, res) : null; + const oldData = getOldData ? getOldData(req, res) : null; + const newData = getNewData ? getNewData(req, res) : null; + + const metadata = { + method: req.method, + url: req.originalUrl, + userAgent: req.get('User-Agent'), + ip: req.ip, + statusCode: res.statusCode, + timestamp: new Date().toISOString() + }; + + await this.auditService.logAdminAction( + actionType, + actorId, + targetId, + oldData, + newData, + metadata + ); + } + } catch (error) { + console.error('Error in audit middleware:', error); + } + }.bind(this)); + + next(); + }; + } + + auditVestingChanges() { + return this.auditAction( + 'VESTING_CHANGE', + (req) => req.user?.id || req.body.adminId || 'unknown', + (req) => req.params.beneficiaryId || req.body.beneficiaryId, + (req) => req.body.previousData, + (req) => req.body.newData + ); + } + + auditCliffDateChanges() { + return this.auditAction( + 'CLIFF_DATE_CHANGE', + (req) => req.user?.id || req.body.adminId || 'unknown', + (req) => req.params.beneficiaryId || req.body.beneficiaryId, + (req) => ({ previousCliffDate: req.body.previousCliffDate }), + (req) => ({ newCliffDate: req.body.newCliffDate }) + ); + } + + auditBeneficiaryChanges() { + return this.auditAction( + 'BENEFICIARY_CHANGE', + (req) => req.user?.id || req.body.adminId || 'unknown', + (req) => req.params.beneficiaryId || req.body.beneficiaryId, + (req) => req.body.previousBeneficiaryData, + (req) => req.body.newBeneficiaryData + ); + } + + auditAdminActions() { + return this.auditAction( + 'ADMIN_ACTION', + (req) => req.user?.id || req.body.adminId || 'unknown', + (req) => req.params.id || req.body.targetId, + null, + (req) => ({ + action: req.body.action, + changes: req.body.changes, + requestBody: req.body + }) + ); + } + + manualAudit(actionType, actorId, targetId = null, oldData = null, newData = null, metadata = null) { + return async (req, res, next) => { + try { + await this.auditService.logAdminAction( + actionType, + actorId, + targetId, + oldData, + newData, + metadata + ); + + res.json({ success: true, message: 'Audit log created successfully' }); + } catch (error) { + console.error('Error in manual audit:', error); + res.status(500).json({ error: 'Failed to create audit log' }); + } + }; + } +} + +module.exports = new AuditMiddleware(); diff --git a/models/AuditLog.js b/models/AuditLog.js new file mode 100644 index 00000000..38d7498c --- /dev/null +++ b/models/AuditLog.js @@ -0,0 +1,281 @@ +const sqlite3 = require('sqlite3').verbose(); +const crypto = require('crypto'); +const path = require('path'); + +class AuditLog { + constructor() { + this.db = new sqlite3.Database(path.join(__dirname, '../data/audit.db')); + this.initDatabase(); + } + + initDatabase() { + this.db.serialize(() => { + this.db.run(` + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + action_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + target_id TEXT, + old_data TEXT, + new_data TEXT, + hash TEXT NOT NULL, + previous_hash TEXT, + nonce TEXT NOT NULL, + metadata TEXT + ) + `); + + this.db.run(` + CREATE TABLE IF NOT EXISTS daily_hashes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE UNIQUE NOT NULL, + root_hash TEXT NOT NULL, + stellar_transaction_id TEXT, + anchored_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.db.run(` + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp); + `); + + this.db.run(` + CREATE INDEX IF NOT EXISTS idx_audit_action_type ON audit_logs(action_type); + `); + }); + } + + generateHash(data, previousHash = null) { + const nonce = crypto.randomBytes(16).toString('hex'); + const hashData = { + ...data, + previousHash, + nonce, + timestamp: new Date().toISOString() + }; + + const hashString = JSON.stringify(hashData, Object.keys(hashData).sort()); + const hash = crypto.createHash('sha256').update(hashString).digest('hex'); + + return { hash, nonce }; + } + + async createLogEntry(actionType, actorId, targetId = null, oldData = null, newData = null, metadata = null) { + return new Promise((resolve, reject) => { + this.db.get( + 'SELECT hash FROM audit_logs ORDER BY id DESC LIMIT 1', + [], + (err, row) => { + if (err) { + reject(err); + return; + } + + const previousHash = row ? row.hash : null; + const entryData = { + actionType, + actorId, + targetId, + oldData, + newData, + metadata + }; + + const { hash, nonce } = this.generateHash(entryData, previousHash); + + this.db.run( + `INSERT INTO audit_logs + (action_type, actor_id, target_id, old_data, new_data, hash, previous_hash, nonce, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + actionType, + actorId, + targetId, + oldData ? JSON.stringify(oldData) : null, + newData ? JSON.stringify(newData) : null, + hash, + previousHash, + nonce, + metadata ? JSON.stringify(metadata) : null + ], + function(err) { + if (err) { + reject(err); + } else { + resolve({ id: this.lastID, hash, previousHash }); + } + } + ); + } + ); + }); + } + + async getLogsByDateRange(startDate, endDate) { + return new Promise((resolve, reject) => { + this.db.all( + `SELECT * FROM audit_logs + WHERE timestamp BETWEEN ? AND ? + ORDER BY timestamp ASC`, + [startDate, endDate], + (err, rows) => { + if (err) { + reject(err); + } else { + const logs = rows.map(row => ({ + ...row, + old_data: row.old_data ? JSON.parse(row.old_data) : null, + new_data: row.new_data ? JSON.parse(row.new_data) : null, + metadata: row.metadata ? JSON.parse(row.metadata) : null + })); + resolve(logs); + } + } + ); + }); + } + + async verifyChainIntegrity(logId = null) { + return new Promise((resolve, reject) => { + const query = logId + ? 'SELECT * FROM audit_logs WHERE id <= ? ORDER BY id ASC' + : 'SELECT * FROM audit_logs ORDER BY id ASC'; + + const params = logId ? [logId] : []; + + this.db.all(query, params, (err, rows) => { + if (err) { + reject(err); + return; + } + + if (rows.length === 0) { + resolve({ valid: true, message: 'No logs to verify' }); + return; + } + + let isValid = true; + let breakPoint = null; + + for (let i = 0; i < rows.length; i++) { + const currentLog = rows[i]; + + if (i === 0) { + if (currentLog.previous_hash !== null) { + isValid = false; + breakPoint = currentLog.id; + break; + } + } else { + const previousLog = rows[i - 1]; + if (currentLog.previous_hash !== previousLog.hash) { + isValid = false; + breakPoint = currentLog.id; + break; + } + } + + const entryData = { + actionType: currentLog.action_type, + actorId: currentLog.actor_id, + targetId: currentLog.target_id, + oldData: currentLog.old_data ? JSON.parse(currentLog.old_data) : null, + newData: currentLog.new_data ? JSON.parse(currentLog.new_data) : null, + metadata: currentLog.metadata ? JSON.parse(currentLog.metadata) : null + }; + + const { hash } = this.generateHash(entryData, currentLog.previous_hash); + if (hash !== currentLog.hash) { + isValid = false; + breakPoint = currentLog.id; + break; + } + } + + resolve({ + valid: isValid, + breakPoint, + totalLogs: rows.length, + message: isValid ? 'Chain integrity verified' : `Chain broken at log ID ${breakPoint}` + }); + }); + }); + } + + async calculateDailyRootHash(date) { + return new Promise((resolve, reject) => { + const startDate = new Date(date); + const endDate = new Date(date); + endDate.setDate(endDate.getDate() + 1); + + this.getLogsByDateRange(startDate.toISOString(), endDate.toISOString()) + .then(logs => { + if (logs.length === 0) { + resolve(null); + return; + } + + let currentHash = null; + for (const log of logs) { + currentHash = log.hash; + } + + const rootData = { + date, + logCount: logs.length, + finalHash: currentHash, + calculatedAt: new Date().toISOString() + }; + + const rootHash = crypto.createHash('sha256') + .update(JSON.stringify(rootData, Object.keys(rootData).sort())) + .digest('hex'); + + resolve({ rootHash, logCount: logs.length, finalHash: currentHash }); + }) + .catch(reject); + }); + } + + async saveDailyHash(date, rootHash, stellarTxId = null) { + return new Promise((resolve, reject) => { + this.db.run( + `INSERT OR REPLACE INTO daily_hashes + (date, root_hash, stellar_transaction_id, anchored_at) + VALUES (?, ?, ?, ?)`, + [date, rootHash, stellarTxId, stellarTxId ? new Date().toISOString() : null], + function(err) { + if (err) { + reject(err); + } else { + resolve({ id: this.lastID, date, rootHash }); + } + } + ); + }); + } + + async getDailyHash(date) { + return new Promise((resolve, reject) => { + this.db.get( + 'SELECT * FROM daily_hashes WHERE date = ?', + [date], + (err, row) => { + if (err) { + reject(err); + } else { + resolve(row); + } + } + ); + }); + } + + close() { + this.db.close(); + } +} + +module.exports = AuditLog; diff --git a/package.json b/package.json index c40cf5ce..df851d2c 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,7 @@ "main": "index.js", "scripts": { "start": "node index.js", - "test": "node test-unit.js", - "test:integration": "node test-pagination.js", - "test:load": "node scripts/run-tge-load-test.js", - "test:load:basic": "node scripts/run-tge-load-test.js basic", - "test:load:comprehensive": "node scripts/run-tge-load-test.js comprehensive", - "test:load:quick": "node scripts/run-tge-load-test.js quick", - "dev": "node --watch index.js" + }, "dependencies": { "@apollo/server": "^5.4.0", @@ -20,14 +14,6 @@ "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", - "p-retry": "^7.1.1", - "pg": "^8.18.0", - "socket.io": "^4.8.3", - "socket.io-redis": "^5.4.0", - "stellar-sdk": "^13.3.0" - }, - "devDependencies": { - "artillery": "^2.0.0", - "ethers": "^6.8.0" + } } diff --git a/routes/audit.js b/routes/audit.js new file mode 100644 index 00000000..5bb600b7 --- /dev/null +++ b/routes/audit.js @@ -0,0 +1,171 @@ +const express = require('express'); +const AuditService = require('../services/AuditService'); +const auditMiddleware = require('../middleware/auditMiddleware'); + +const router = express.Router(); +const auditService = new AuditService(); + +router.get('/verify', async (req, res) => { + try { + const { startDate, endDate } = req.query; + const result = await auditService.verifyAuditTrail(startDate, endDate); + + res.json({ + success: true, + ...result + }); + } catch (error) { + console.error('Error verifying audit trail:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to verify audit trail' + }); + } +}); + +router.get('/history', async (req, res) => { + try { + const filters = { + startDate: req.query.startDate, + endDate: req.query.endDate, + actionType: req.query.actionType, + actorId: req.query.actorId, + limit: parseInt(req.query.limit) || 100 + }; + + const logs = await auditService.getAuditHistory(filters); + + res.json({ + success: true, + data: logs, + count: logs.length + }); + } catch (error) { + console.error('Error retrieving audit history:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to retrieve audit history' + }); + } +}); + +router.post('/anchor', async (req, res) => { + try { + const { date } = req.body; + const result = await auditService.anchorDailyLogs(date); + + res.json({ + success: result.success, + ...result + }); + } catch (error) { + console.error('Error anchoring daily logs:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to anchor daily logs' + }); + } +}); + +router.get('/stellar/account', async (req, res) => { + try { + const accountInfo = await auditService.getStellarAccountInfo(); + + res.json({ + success: true, + data: accountInfo + }); + } catch (error) { + console.error('Error getting Stellar account info:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to get Stellar account information' + }); + } +}); + +router.post('/manual', auditMiddleware.manualAudit( + 'MANUAL_AUDIT', + (req) => req.body.actorId || req.ip, + (req) => req.body.targetId, + (req) => req.body.oldData, + (req) => req.body.newData, + (req) => req.body.metadata +)); + +router.get('/chain-integrity', async (req, res) => { + try { + const { logId } = req.query; + const result = await auditService.auditLog.verifyChainIntegrity(logId ? parseInt(logId) : null); + + res.json({ + success: true, + ...result + }); + } catch (error) { + console.error('Error checking chain integrity:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to check chain integrity' + }); + } +}); + +router.get('/daily-hashes', async (req, res) => { + try { + const { startDate, endDate } = req.query; + + let query = 'SELECT * FROM daily_hashes'; + let params = []; + + if (startDate || endDate) { + query += ' WHERE'; + const conditions = []; + + if (startDate) { + conditions.push(' date >= ?'); + params.push(startDate); + } + + if (endDate) { + conditions.push(' date <= ?'); + params.push(endDate); + } + + query += conditions.join(' AND'); + } + + query += ' ORDER BY date DESC'; + + auditService.auditLog.db.all(query, params, (err, rows) => { + if (err) { + console.error('Error retrieving daily hashes:', err); + res.status(500).json({ + success: false, + error: err.message, + message: 'Failed to retrieve daily hashes' + }); + } else { + res.json({ + success: true, + data: rows, + count: rows.length + }); + } + }); + } catch (error) { + console.error('Error in daily hashes endpoint:', error); + res.status(500).json({ + success: false, + error: error.message, + message: 'Failed to retrieve daily hashes' + }); + } +}); + +module.exports = router; diff --git a/services/AuditService.js b/services/AuditService.js new file mode 100644 index 00000000..4a12f0aa --- /dev/null +++ b/services/AuditService.js @@ -0,0 +1,236 @@ +const AuditLog = require('../models/AuditLog'); +const StellarService = require('./StellarService'); +const cron = require('node-cron'); + +class AuditService { + constructor() { + this.auditLog = new AuditLog(); + this.stellarService = new StellarService(); + this.initDailyAnchoring(); + } + + initDailyAnchoring() { + cron.schedule('0 2 * * *', async () => { + console.log('Running daily audit log anchoring job...'); + await this.anchorDailyLogs(); + }); + + console.log('Daily audit anchoring scheduled for 2:00 AM UTC'); + } + + async logAdminAction(actionType, actorId, targetId = null, oldData = null, newData = null, metadata = null) { + try { + const result = await this.auditLog.createLogEntry( + actionType, + actorId, + targetId, + oldData, + newData, + metadata + ); + + console.log(`Audit log created: ${result.id} with hash: ${result.hash}`); + return result; + + } catch (error) { + console.error('Error creating audit log:', error); + throw error; + } + } + + async anchorDailyLogs(date = null) { + try { + const targetDate = date || new Date().toISOString().split('T')[0]; + + console.log(`Calculating root hash for ${targetDate}`); + + const dailyHashResult = await this.auditLog.calculateDailyRootHash(targetDate); + + if (!dailyHashResult) { + console.log(`No audit logs found for ${targetDate}`); + return { success: false, message: 'No logs to anchor' }; + } + + const existingHash = await this.auditLog.getDailyHash(targetDate); + if (existingHash && existingHash.stellar_transaction_id) { + console.log(`Logs for ${targetDate} already anchored with transaction: ${existingHash.stellar_transaction_id}`); + return { + success: true, + alreadyAnchored: true, + transactionId: existingHash.stellar_transaction_id, + rootHash: existingHash.root_hash + }; + } + + console.log(`Anchoring root hash: ${dailyHashResult.rootHash}`); + + const anchorResult = await this.stellarService.anchorRootHash( + dailyHashResult.rootHash, + targetDate + ); + + if (anchorResult.success) { + await this.auditLog.saveDailyHash( + targetDate, + dailyHashResult.rootHash, + anchorResult.transactionId + ); + + console.log(`Successfully anchored ${dailyHashResult.logCount} logs for ${targetDate}`); + console.log(`Transaction ID: ${anchorResult.transactionId}`); + + return { + success: true, + transactionId: anchorResult.transactionId, + rootHash: dailyHashResult.rootHash, + logCount: dailyHashResult.logCount, + date: targetDate, + message: anchorResult.message + }; + } else { + console.error('Failed to anchor root hash:', anchorResult.error); + return { + success: false, + error: anchorResult.error, + message: anchorResult.message + }; + } + + } catch (error) { + console.error('Error in daily anchoring process:', error); + return { + success: false, + error: error.message, + message: 'Daily anchoring process failed' + }; + } + } + + async verifyAuditTrail(startDate = null, endDate = null) { + try { + const chainIntegrity = await this.auditLog.verifyChainIntegrity(); + + if (!chainIntegrity.valid) { + return { + valid: false, + chainIntegrity, + message: 'Audit trail chain integrity compromised' + }; + } + + let verificationResults = { + chainIntegrity, + dailyAnchors: [], + overallValid: true + }; + + if (startDate && endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + + for (let date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) { + const dateStr = date.toISOString().split('T')[0]; + const dailyHash = await this.auditLog.getDailyHash(dateStr); + + if (dailyHash && dailyHash.stellar_transaction_id) { + const stellarVerification = await this.stellarService.verifyAnchoredTransaction( + dailyHash.stellar_transaction_id, + dailyHash.root_hash, + dateStr + ); + + verificationResults.dailyAnchors.push({ + date: dateStr, + rootHash: dailyHash.root_hash, + transactionId: dailyHash.stellar_transaction_id, + verified: stellarVerification.verified, + message: stellarVerification.message + }); + + if (!stellarVerification.verified) { + verificationResults.overallValid = false; + } + } + } + } + + return { + valid: verificationResults.overallValid, + ...verificationResults, + message: verificationResults.overallValid + ? 'Audit trail fully verified and immutable' + : 'Audit trail verification failed' + }; + + } catch (error) { + console.error('Error verifying audit trail:', error); + return { + valid: false, + error: error.message, + message: 'Audit trail verification failed' + }; + } + } + + async getAuditHistory(filters = {}) { + try { + const { startDate, endDate, actionType, actorId, limit = 100 } = filters; + + let whereClause = '1=1'; + let params = []; + + if (startDate) { + whereClause += ' AND timestamp >= ?'; + params.push(startDate); + } + + if (endDate) { + whereClause += ' AND timestamp <= ?'; + params.push(endDate); + } + + if (actionType) { + whereClause += ' AND action_type = ?'; + params.push(actionType); + } + + if (actorId) { + whereClause += ' AND actor_id = ?'; + params.push(actorId); + } + + return new Promise((resolve, reject) => { + this.auditLog.db.all( + `SELECT * FROM audit_logs + WHERE ${whereClause} + ORDER BY timestamp DESC + LIMIT ?`, + [...params, limit], + (err, rows) => { + if (err) { + reject(err); + } else { + const logs = rows.map(row => ({ + ...row, + old_data: row.old_data ? JSON.parse(row.old_data) : null, + new_data: row.new_data ? JSON.parse(row.new_data) : null, + metadata: row.metadata ? JSON.parse(row.metadata) : null + })); + resolve(logs); + } + } + ); + }); + + } catch (error) { + console.error('Error retrieving audit history:', error); + throw error; + } + } + + async getStellarAccountInfo() { + return await this.stellarService.getAccountInfo(); + } +} + +module.exports = AuditService; diff --git a/services/StellarService.js b/services/StellarService.js new file mode 100644 index 00000000..e0ed256a --- /dev/null +++ b/services/StellarService.js @@ -0,0 +1,138 @@ +const StellarSdk = require('stellar-sdk'); +const dotenv = require('dotenv'); + +dotenv.config(); + +class StellarService { + constructor() { + this.server = new StellarSdk.Server(process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'); + this.auditAccountPublicKey = process.env.STELLAR_AUDIT_ACCOUNT_PUBLIC_KEY; + this.auditAccountSecret = process.env.STELLAR_AUDIT_ACCOUNT_SECRET_KEY; + + if (!this.auditAccountPublicKey || !this.auditAccountSecret) { + console.warn('Stellar audit account credentials not configured. Anchoring will be simulated.'); + this.simulationMode = true; + } else { + this.simulationMode = false; + } + } + + async anchorRootHash(rootHash, date) { + try { + if (this.simulationMode) { + console.log(`[SIMULATION] Anchoring root hash for ${date}: ${rootHash}`); + return { + success: true, + transactionId: `SIM_${Date.now()}_${rootHash.substring(0, 8)}`, + message: 'Simulated anchoring (no real Stellar transaction)' + }; + } + + const account = await this.server.loadAccount(this.auditAccountPublicKey); + const fee = await this.server.fetchBaseFee(); + + const memo = `AUDIT:${date}:${rootHash}`; + + const transaction = new StellarSdk.TransactionBuilder(account, { + fee: fee.toString(), + networkPassphrase: StellarSdk.Networks.TESTNET + }) + .addOperation(StellarSdk.Operation.payment({ + destination: this.auditAccountPublicKey, + asset: StellarSdk.Asset.native(), + amount: '0.0000001' + })) + .addMemo(StellarSdk.Memo.text(memo)) + .setTimeout(30) + .build(); + + const keyPair = StellarSdk.Keypair.fromSecret(this.auditAccountSecret); + transaction.sign(keyPair); + + const result = await this.server.submitTransaction(transaction); + + return { + success: true, + transactionId: result.id, + message: `Root hash anchored to Stellar ledger`, + ledger: result.ledger + }; + + } catch (error) { + console.error('Error anchoring to Stellar:', error); + return { + success: false, + error: error.message, + message: 'Failed to anchor root hash to Stellar ledger' + }; + } + } + + async verifyAnchoredTransaction(transactionId, expectedHash, date) { + try { + if (this.simulationMode && transactionId.startsWith('SIM_')) { + return { + verified: true, + message: 'Simulated transaction verification' + }; + } + + const transaction = await this.server.transactions().transaction(transactionId).call(); + const memo = transaction.memo; + + const expectedMemo = `AUDIT:${date}:${expectedHash}`; + + if (memo !== expectedMemo) { + return { + verified: false, + message: `Memo mismatch. Expected: ${expectedMemo}, Found: ${memo}` + }; + } + + return { + verified: true, + message: 'Transaction verified successfully', + ledger: transaction.ledger, + createdAt: transaction.created_at + }; + + } catch (error) { + console.error('Error verifying Stellar transaction:', error); + return { + verified: false, + error: error.message, + message: 'Failed to verify transaction' + }; + } + } + + async getAccountInfo() { + try { + if (this.simulationMode) { + return { + publicKey: this.auditAccountPublicKey || 'SIMULATED', + balance: '1000.0000000', + network: 'testnet-simulation' + }; + } + + const account = await this.server.loadAccount(this.auditAccountPublicKey); + const balance = account.balances.find(b => b.asset_type === 'native'); + + return { + publicKey: this.auditAccountPublicKey, + balance: balance ? balance.balance : '0', + network: 'testnet' + }; + + } catch (error) { + console.error('Error getting account info:', error); + return { + error: error.message, + publicKey: this.auditAccountPublicKey + }; + } + } +} + +module.exports = StellarService; diff --git a/tests/audit.test.js b/tests/audit.test.js new file mode 100644 index 00000000..f37c8127 --- /dev/null +++ b/tests/audit.test.js @@ -0,0 +1,179 @@ +const request = require('supertest'); +const AuditLog = require('../models/AuditLog'); +const AuditService = require('../services/AuditService'); +const app = require('../index'); + +describe('Audit Log System', () => { + let auditLog; + let auditService; + + beforeAll(async () => { + auditLog = new AuditLog(); + auditService = new AuditService(); + }); + + afterAll(async () => { + if (auditLog) { + auditLog.close(); + } + }); + + describe('Audit Log Creation', () => { + test('should create audit log entry successfully', async () => { + const result = await auditService.logAdminAction( + 'TEST_ACTION', + 'test-user-123', + 'target-456', + { old: 'value' }, + { new: 'value' }, + { test: true } + ); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.hash).toBeDefined(); + expect(result.previousHash).toBeDefined(); + }); + + test('should create chained audit logs', async () => { + const result1 = await auditService.logAdminAction( + 'CHAIN_TEST_1', + 'test-user-123' + ); + + const result2 = await auditService.logAdminAction( + 'CHAIN_TEST_2', + 'test-user-123' + ); + + expect(result2.previousHash).toBe(result1.hash); + expect(result2.hash).not.toBe(result1.hash); + }); + }); + + describe('Chain Integrity Verification', () => { + test('should verify chain integrity', async () => { + await auditService.logAdminAction('INTEGRITY_TEST_1', 'user1'); + await auditService.logAdminAction('INTEGRITY_TEST_2', 'user2'); + await auditService.logAdminAction('INTEGRITY_TEST_3', 'user3'); + + const verification = await auditService.verifyAuditTrail(); + + expect(verification.valid).toBe(true); + expect(verification.chainIntegrity.valid).toBe(true); + }); + }); + + describe('API Endpoints', () => { + test('GET / should return API information', async () => { + const response = await request(app) + .get('/') + .expect(200); + + expect(response.body.project).toBe('Vesting Vault'); + expect(response.body.features).toContain('Tamper-proof audit logging'); + }); + + test('POST /api/vesting/cliff-date should create audit log', async () => { + const response = await request(app) + .post('/api/vesting/cliff-date') + .send({ + beneficiaryId: 'beneficiary-123', + previousCliffDate: '2024-01-01', + newCliffDate: '2024-06-01', + adminId: 'admin-456' + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + test('POST /api/admin/action should create audit log', async () => { + const response = await request(app) + .post('/api/admin/action') + .send({ + action: 'modify_vesting', + targetId: 'target-789', + changes: { amount: 1000 }, + adminId: 'admin-456' + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + test('GET /api/audit/history should return audit logs', async () => { + await auditService.logAdminAction('HISTORY_TEST', 'test-user'); + + const response = await request(app) + .get('/api/audit/history') + .expect(200); + + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + test('GET /api/audit/chain-integrity should verify chain', async () => { + const response = await request(app) + .get('/api/audit/chain-integrity') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.valid).toBeDefined(); + }); + + test('GET /api/audit/stellar/account should return account info', async () => { + const response = await request(app) + .get('/api/audit/stellar/account') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + }); + }); + + describe('Daily Hash Calculation', () => { + test('should calculate daily root hash', async () => { + const today = new Date().toISOString().split('T')[0]; + + await auditService.logAdminAction('DAILY_TEST_1', 'user1'); + await auditService.logAdminAction('DAILY_TEST_2', 'user2'); + + const rootHashResult = await auditLog.calculateDailyRootHash(today); + + expect(rootHashResult).toBeDefined(); + expect(rootHashResult.rootHash).toBeDefined(); + expect(rootHashResult.logCount).toBeGreaterThan(0); + }); + + test('should anchor daily logs', async () => { + const today = new Date().toISOString().split('T')[0]; + + await auditService.logAdminAction('ANCHOR_TEST', 'user1'); + + const result = await auditService.anchorDailyLogs(today); + + expect(result).toBeDefined(); + expect(result.success).toBeDefined(); + }); + }); + + describe('Manual Audit Creation', () => { + test('POST /api/audit/manual should create manual audit', async () => { + const response = await request(app) + .post('/api/audit/manual') + .send({ + actorId: 'manual-user', + targetId: 'manual-target', + oldData: { status: 'old' }, + newData: { status: 'new' }, + metadata: { source: 'manual' } + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); +}); + +module.exports = {}; diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 00000000..d658f3a2 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +beforeAll(() => { + const testDbDir = path.join(__dirname, '../data'); + if (!fs.existsSync(testDbDir)) { + fs.mkdirSync(testDbDir, { recursive: true }); + } +}); + +afterAll(() => { + const testDbDir = path.join(__dirname, '../data'); + if (fs.existsSync(testDbDir)) { + const files = fs.readdirSync(testDbDir); + files.forEach(file => { + fs.unlinkSync(path.join(testDbDir, file)); + }); + } +});