diff --git a/backend/server.js b/backend/server.js index 18dbfd7..cdc10b5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -55,6 +55,7 @@ const reportsRoutes = require('./src/routes/reports'); const riskAssessmentRoutes = require('./src/routes/riskAssessment'); // Issue #187 const auditRoutes = require('./src/routes/audit'); // Issue #194 const rateLimitMetricsRoutes = require('./src/routes/rateLimitMetrics'); // Issue #198 +const stateSnapshotRoutes = require('./src/routes/stateSnapshots'); // Issue #227 // Import services @@ -249,6 +250,7 @@ class OrynBackendServer { this.app.use('/api/risk-assessment', riskAssessmentRoutes); // Issue #187 this.app.use('/api/audit', auditRoutes); // Issue #194 — centralized audit logging this.app.use('/api/admin/rate-limit-metrics', rateLimitMetricsRoutes); // Issue #198 + this.app.use('/api/snapshots', stateSnapshotRoutes); // Issue #227 — protocol state snapshots // Transaction routes (mixed auth - some endpoints require auth, others don't) this.app.use('/api/transactions', transactionRoutes); diff --git a/backend/src/controllers/stateSnapshotController.js b/backend/src/controllers/stateSnapshotController.js new file mode 100644 index 0000000..d2b2702 --- /dev/null +++ b/backend/src/controllers/stateSnapshotController.js @@ -0,0 +1,275 @@ +/** + * State Snapshot Controller (Issue #227) + * + * REST API endpoints for managing protocol state snapshots: + * - Capture complete protocol state + * - List and retrieve snapshots + * - Verify snapshot integrity + * - Execute rollback to previous state + * - Manage automated scheduling + */ + +const stateSnapshotService = require('../services/stateSnapshotService'); +const logger = require('../config/logger'); + +class StateSnapshotController { + /** + * POST /api/snapshots + * Capture a new protocol state snapshot. + * Body: { description } + */ + static async createSnapshot(req, res) { + try { + const { description } = req.body; + const actor = { + walletAddress: req.user?.walletAddress || 'system', + isGovernanceAction: req.body.isGovernanceAction || false, + proposalId: req.body.proposalId || null, + }; + + const snapshot = await stateSnapshotService.captureStateSnapshot({ + description: description || 'Manual protocol state snapshot', + actor, + }); + + logger.info('Snapshot created via API', { + snapshotId: snapshot.snapshotId, + version: snapshot.version, + createdBy: actor.walletAddress, + }); + + res.status(201).json({ + success: true, + message: 'Protocol state snapshot captured successfully', + data: { + snapshotId: snapshot.snapshotId, + version: snapshot.version, + timestamp: snapshot.timestamp, + contractCount: snapshot.contractCount, + status: snapshot.status, + stateHash: snapshot.stateHash, + }, + }); + } catch (error) { + logger.error('Failed to create snapshot', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * GET /api/snapshots + * List snapshots with optional filters. + * Query: ?status=verified&minVersion=1&maxVersion=10&page=1&limit=50 + */ + static async listSnapshots(req, res) { + try { + const { status, minVersion, maxVersion, page, limit } = req.query; + const result = await stateSnapshotService.listSnapshots({ + status, + minVersion, + maxVersion, + page, + limit, + }); + + res.json({ + success: true, + data: result.snapshots, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + pages: Math.ceil(result.total / result.limit), + }, + }); + } catch (error) { + logger.error('Failed to list snapshots', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * GET /api/snapshots/stats + * Get snapshot statistics for dashboards. + */ + static async getSnapshotStats(req, res) { + try { + const stats = await stateSnapshotService.getSnapshotStats(); + res.json({ success: true, data: stats }); + } catch (error) { + logger.error('Failed to get snapshot stats', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * GET /api/snapshots/:snapshotId + * Get a single snapshot by ID. + */ + static async getSnapshot(req, res) { + try { + const { snapshotId } = req.params; + const snapshot = await stateSnapshotService.getSnapshot(snapshotId); + + if (!snapshot) { + return res.status(404).json({ + success: false, + message: `Snapshot '${snapshotId}' not found`, + }); + } + + res.json({ success: true, data: snapshot }); + } catch (error) { + logger.error('Failed to get snapshot', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * POST /api/snapshots/:snapshotId/verify + * Verify the integrity of a snapshot by recomputing its state hash. + */ + static async verifySnapshot(req, res) { + try { + const { snapshotId } = req.params; + const result = await stateSnapshotService.verifySnapshotIntegrity(snapshotId); + + res.json({ + success: true, + message: `Snapshot integrity ${result.isValid ? 'verified' : 'check FAILED'}`, + data: result, + }); + } catch (error) { + logger.error('Failed to verify snapshot', error); + const statusCode = error.message.includes('not found') ? 404 : 500; + res.status(statusCode).json({ success: false, message: error.message }); + } + } + + /** + * POST /api/snapshots/:snapshotId/rollback + * Execute a protocol rollback to the specified snapshot. + * Body: { reason } + */ + static async executeRollback(req, res) { + try { + const { snapshotId } = req.params; + const { reason } = req.body; + + if (!reason) { + return res.status(400).json({ + success: false, + message: 'Rollback reason is required', + }); + } + + const actor = { + walletAddress: req.user?.walletAddress || 'system', + }; + + const result = await stateSnapshotService.executeRollback(snapshotId, { + actor, + reason, + }); + + logger.warn('Rollback executed via API', { + snapshotId, + initiatedBy: actor.walletAddress, + reason, + }); + + res.json({ + success: true, + message: 'Protocol rollback executed successfully', + data: result, + }); + } catch (error) { + logger.error('Failed to execute rollback', error); + const statusCode = error.message.includes('not found') ? 404 : 400; + res.status(statusCode).json({ success: false, message: error.message }); + } + } + + /** + * POST /api/snapshots/scheduling/start + * Start automated snapshot scheduling. + * Body: { intervalMinutes } + */ + static async startScheduling(req, res) { + try { + const { intervalMinutes } = req.body; + stateSnapshotService.startScheduledSnapshots(intervalMinutes || null); + + res.json({ + success: true, + message: 'Automated snapshot scheduling started', + data: { + isScheduling: stateSnapshotService.isScheduling, + intervalMinutes: intervalMinutes || parseInt(process.env.SNAPSHOT_INTERVAL_MINUTES || '360', 10), + }, + }); + } catch (error) { + logger.error('Failed to start snapshot scheduling', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * POST /api/snapshots/scheduling/stop + * Stop automated snapshot scheduling. + */ + static async stopScheduling(req, res) { + try { + stateSnapshotService.stopScheduledSnapshots(); + + res.json({ + success: true, + message: 'Automated snapshot scheduling stopped', + data: { isScheduling: false }, + }); + } catch (error) { + logger.error('Failed to stop snapshot scheduling', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * GET /api/snapshots/scheduling/status + * Get current scheduling status. + */ + static async getSchedulingStatus(req, res) { + try { + res.json({ + success: true, + data: { + isScheduling: stateSnapshotService.isScheduling, + retentionDays: stateSnapshotService.defaultRetentionDays, + maxSnapshots: stateSnapshotService.maxSnapshots, + }, + }); + } catch (error) { + logger.error('Failed to get scheduling status', error); + res.status(500).json({ success: false, message: error.message }); + } + } + + /** + * POST /api/snapshots/prune + * Manually prune expired snapshots. + */ + static async pruneExpired(req, res) { + try { + const result = await stateSnapshotService.pruneExpiredSnapshots(); + res.json({ + success: true, + message: `Pruned ${result.deletedCount || 0} expired snapshots`, + data: result, + }); + } catch (error) { + logger.error('Failed to prune expired snapshots', error); + res.status(500).json({ success: false, message: error.message }); + } + } +} + +module.exports = StateSnapshotController; diff --git a/backend/src/models/StateSnapshot.js b/backend/src/models/StateSnapshot.js new file mode 100644 index 0000000..bf7fa14 --- /dev/null +++ b/backend/src/models/StateSnapshot.js @@ -0,0 +1,159 @@ +const mongoose = require('mongoose'); + +/** + * StateSnapshot (Issue #227) + * + * Records protocol-wide state snapshots captured from Soroban contracts. + * Each snapshot captures the complete state of markets, liquidity pools, + * governance, oracle data, and treasury at a point in time. + * + * Snapshots are immutable after creation and verified for integrity before + * restoration. Supports versioned rollback for deployment failures and + * emergency protocol upgrades. + */ + +const SNAPSHOT_STATUSES = ['created', 'verified', 'restoring', 'restored', 'corrupted', 'expired']; + +const stateSnapshotSchema = new mongoose.Schema({ + snapshotId: { + type: String, + required: true, + unique: true, + index: true, + }, + version: { + type: Number, + required: true, + min: 1, + }, + timestamp: { + type: Date, + required: true, + index: true, + }, + contractCount: { + type: Number, + required: true, + min: 0, + }, + status: { + type: String, + enum: SNAPSHOT_STATUSES, + default: 'created', + index: true, + }, + createdBy: { + walletAddress: { type: String, index: true }, + isGovernanceAction: { type: Boolean, default: false }, + proposalId: { type: String }, + }, + parentSnapshotId: { + type: String, + default: null, + }, + description: { + type: String, + maxlength: 500, + }, + stateHash: { + type: String, + required: true, + index: true, + }, + stateData: { + markets: { + type: [mongoose.Schema.Types.Mixed], + default: [], + }, + liquidityPools: { + type: [mongoose.Schema.Types.Mixed], + default: [], + }, + governance: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + oracleData: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + treasury: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, + }, + contractsRegistry: [ + { + contractAddress: { type: String }, + contractType: { type: String }, + stateKeys: { type: [String] }, + stateHash: { type: String }, + }, + ], + integrityChecks: [ + { + checkedAt: { type: Date }, + passed: { type: Boolean }, + details: { type: String }, + }, + ], + rollbackHistory: [ + { + rolledBackAt: { type: Date }, + rolledBackBy: { type: String }, + reason: { type: String }, + previousSnapshotId: { type: String }, + }, + ], + metadata: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, +}, { + timestamps: true, + collection: 'state_snapshots', +}); + +stateSnapshotSchema.index({ status: 1, timestamp: -1 }); +stateSnapshotSchema.index({ version: -1 }); +stateSnapshotSchema.index({ 'createdBy.walletAddress': 1, timestamp: -1 }); + +stateSnapshotSchema.statics.findLatestVerified = function () { + return this.findOne({ status: 'verified' }) + .sort({ version: -1, timestamp: -1 }) + .lean(); +}; + +stateSnapshotSchema.statics.findByVersionRange = function (minVersion, maxVersion) { + const query = {}; + if (minVersion !== undefined) query.version = { $gte: minVersion }; + if (maxVersion !== undefined) { + query.version = query.version + ? { ...query.version, $lte: maxVersion } + : { $lte: maxVersion }; + } + return this.find(query).sort({ version: -1 }).lean(); +}; + +stateSnapshotSchema.statics.pruneExpired = function (retentionDays = 90) { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); + return this.deleteMany({ + timestamp: { $lt: cutoff }, + status: { $nin: ['restoring', 'restored'] }, + }); +}; + +stateSnapshotSchema.statics.countByStatus = function () { + return this.aggregate([ + { $group: { _id: '$status', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]); +}; + +const StateSnapshot = mongoose.model('StateSnapshot', stateSnapshotSchema); + +if (StateSnapshot) { + StateSnapshot.SNAPSHOT_STATUSES = SNAPSHOT_STATUSES; +} + +module.exports = StateSnapshot; diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 6d4598e..bad9247 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -13,6 +13,7 @@ const YieldSnapshot = require('./YieldSnapshot'); const AuditLog = require('./AuditLog'); const LiquidityPosition = require('./LiquidityPosition'); const TreasuryTransaction = require('./TreasuryTransaction'); +const StateSnapshot = require('./StateSnapshot'); module.exports = { Market, @@ -29,5 +30,6 @@ module.exports = { YieldSnapshot, AuditLog, LiquidityPosition, - TreasuryTransaction + TreasuryTransaction, + StateSnapshot }; diff --git a/backend/src/routes/stateSnapshots.js b/backend/src/routes/stateSnapshots.js new file mode 100644 index 0000000..40c05d9 --- /dev/null +++ b/backend/src/routes/stateSnapshots.js @@ -0,0 +1,53 @@ +/** + * State Snapshot & Rollback Routes (Issue #227) + * + * REST API endpoints for protocol state snapshot management: + * - POST /api/snapshots - Capture a new snapshot + * - GET /api/snapshots - List snapshots (with filters) + * - GET /api/snapshots/stats - Get snapshot statistics + * - GET /api/snapshots/scheduling/status - Get scheduling status + * - POST /api/snapshots/scheduling/start - Start automated scheduling + * - POST /api/snapshots/scheduling/stop - Stop automated scheduling + * - POST /api/snapshots/prune - Prune expired snapshots + * - GET /api/snapshots/:snapshotId - Get snapshot by ID + * - POST /api/snapshots/:snapshotId/verify - Verify snapshot integrity + * - POST /api/snapshots/:snapshotId/rollback - Execute rollback + */ + +const express = require('express'); +const router = express.Router(); +const { asyncHandler } = require('../middleware/errorHandler'); +const { requireAdmin } = require('../middleware/auth'); +const stateSnapshotController = require('../controllers/stateSnapshotController'); + +// GET /api/snapshots/stats - Snapshot statistics (must be before /:snapshotId) +router.get('/stats', asyncHandler(stateSnapshotController.getSnapshotStats)); + +// GET /api/snapshots/scheduling/status - Scheduling status (must be before /:snapshotId) +router.get('/scheduling/status', asyncHandler(stateSnapshotController.getSchedulingStatus)); + +// POST /api/snapshots/scheduling/start - Start automated scheduling (admin only) +router.post('/scheduling/start', requireAdmin, asyncHandler(stateSnapshotController.startScheduling)); + +// POST /api/snapshots/scheduling/stop - Stop automated scheduling (admin only) +router.post('/scheduling/stop', requireAdmin, asyncHandler(stateSnapshotController.stopScheduling)); + +// POST /api/snapshots/prune - Prune expired snapshots (admin only) +router.post('/prune', requireAdmin, asyncHandler(stateSnapshotController.pruneExpired)); + +// POST /api/snapshots - Capture a new snapshot (admin only) +router.post('/', requireAdmin, asyncHandler(stateSnapshotController.createSnapshot)); + +// GET /api/snapshots - List snapshots +router.get('/', asyncHandler(stateSnapshotController.listSnapshots)); + +// GET /api/snapshots/:snapshotId - Get snapshot by ID +router.get('/:snapshotId', asyncHandler(stateSnapshotController.getSnapshot)); + +// POST /api/snapshots/:snapshotId/verify - Verify snapshot integrity (admin only) +router.post('/:snapshotId/verify', requireAdmin, asyncHandler(stateSnapshotController.verifySnapshot)); + +// POST /api/snapshots/:snapshotId/rollback - Execute rollback (admin only) +router.post('/:snapshotId/rollback', requireAdmin, asyncHandler(stateSnapshotController.executeRollback)); + +module.exports = router; diff --git a/backend/src/services/stateSnapshotService.js b/backend/src/services/stateSnapshotService.js new file mode 100644 index 0000000..2d69975 --- /dev/null +++ b/backend/src/services/stateSnapshotService.js @@ -0,0 +1,588 @@ +const crypto = require('crypto'); +const logger = require('../config/logger'); +const contractConfig = require('../config/contracts'); +const auditService = require('./auditService'); + +/** + * State Snapshot Service (Issue #227) + * + * Orchestrates protocol-wide state snapshots across all Soroban contracts. + * Manages: + * - Snapshot creation (market, liquidity, governance, oracle, treasury) + * - Versioned storage with metadata tracking + * - Integrity verification via SHA-256 cryptographic hashing + * - Rollback orchestration to previous protocol state + * - Automated snapshot scheduling + */ + +let StateSnapshotModel = null; +function getModel() { + if (!StateSnapshotModel) { + StateSnapshotModel = require('../models/StateSnapshot'); + } + return StateSnapshotModel; +} + +function generateSnapshotId() { + const ts = Date.now().toString(36); + const random = crypto.randomBytes(8).toString('hex'); + return `SNAP_${ts}_${random}`; +} + +function computeStateHash(stateData) { + const serialized = JSON.stringify(stateData, Object.keys(stateData).sort()); + return crypto.createHash('sha256').update(serialized).digest('hex'); +} + +function computeRegistryHash(entries) { + const sorted = [...entries].sort((a, b) => + (a.contractAddress || '').localeCompare(b.contractAddress || '') + ); + const serialized = JSON.stringify(sorted); + return crypto.createHash('sha256').update(serialized).digest('hex'); +} + +class StateSnapshotService { + constructor() { + this.snapshotInterval = null; + this.isScheduling = false; + this.defaultRetentionDays = parseInt(process.env.SNAPSHOT_RETENTION_DAYS || '90', 10); + this.maxSnapshots = parseInt(process.env.MAX_SNAPSHOTS || '100', 10); + } + + /** + * Capture the complete protocol state into a versioned snapshot. + * + * Aggregates state from all protocol modules: + * - Markets (active, pending, resolved) + * - Liquidity Pools (reserves, fees, LP positions) + * - Governance (proposals, votes, staking) + * - Oracle Data (registered oracles, recent resolutions) + * - Treasury (balance, inflows, outflows, distributions) + * + * @param {Object} options + * @param {string} options.description - Human-readable description + * @param {Object} options.actor - { walletAddress, isGovernanceAction, proposalId } + */ + async captureStateSnapshot(options = {}) { + const { description = 'Manual protocol state snapshot', actor = {} } = options; + const timestamp = new Date(); + + logger.info('Starting protocol state snapshot capture...'); + + try { + const StateSnapshot = getModel(); + + const latestSnapshot = await StateSnapshot.findLatestVerified(); + const parentSnapshotId = latestSnapshot ? latestSnapshot.snapshotId : null; + const nextVersion = latestSnapshot ? latestSnapshot.version + 1 : 1; + + const stateData = await this.aggregateProtocolState(); + + const contractsRegistry = this.buildContractsRegistry(stateData); + + const stateHash = computeStateHash(stateData); + const registryHash = computeRegistryHash(contractsRegistry); + const combinedHash = crypto + .createHash('sha256') + .update(stateHash + registryHash) + .digest('hex'); + + const snapshotId = generateSnapshotId(); + + const snapshot = await StateSnapshot.create({ + snapshotId, + version: nextVersion, + timestamp, + contractCount: contractsRegistry.length, + status: 'created', + createdBy: { + walletAddress: actor.walletAddress || 'system', + isGovernanceAction: actor.isGovernanceAction || false, + proposalId: actor.proposalId || null, + }, + parentSnapshotId, + description, + stateHash: combinedHash, + stateData, + contractsRegistry, + integrityChecks: [], + rollbackHistory: [], + metadata: { + capturedBy: 'stateSnapshotService', + retentionDays: this.defaultRetentionDays, + }, + }); + + logger.info('Protocol state snapshot captured', { + snapshotId, + version: nextVersion, + contractCount: contractsRegistry.length, + stateHash: combinedHash.substring(0, 16), + }); + + try { + await auditService.record({ + category: 'system', + action: 'system.event', + status: 'success', + actor, + target: { type: 'snapshot', id: snapshotId }, + description: `Protocol state snapshot #${nextVersion} captured: ${description}`, + metadata: { + snapshotId, + version: nextVersion, + contractCount: contractsRegistry.length, + stateHash: combinedHash, + }, + }); + } catch (auditError) { + logger.warn('Failed to record snapshot audit event', auditError.message); + } + + return snapshot; + } catch (error) { + logger.error('Failed to capture protocol state snapshot', error); + throw error; + } + } + + /** + * Aggregate state from all protocol modules. + */ + async aggregateProtocolState() { + const stateData = { + markets: [], + liquidityPools: [], + governance: {}, + oracleData: {}, + treasury: {}, + }; + + try { + const { Market, LiquidityPosition, TreasuryTransaction } = require('../models'); + + const [markets, lpPositions, treasuryAgg] = await Promise.all([ + Market.find({}).lean().catch(() => []), + LiquidityPosition.find({}).lean().catch(() => []), + TreasuryTransaction.aggregate([ + { $match: { status: 'completed' } }, + { + $group: { + _id: '$type', + total: { $sum: '$amount' }, + count: { $sum: 1 }, + }, + }, + ]).catch(() => []), + ]); + + stateData.markets = markets.map((m) => ({ + marketId: m._id?.toString() || m.marketId, + question: m.question, + category: m.category, + status: m.status, + totalVolume: m.totalVolume || 0, + totalLiquidity: m.totalLiquidity || 0, + outcome: m.outcome, + expiresAt: m.expiresAt, + })); + + stateData.liquidityPools = lpPositions.map((lp) => ({ + poolId: lp.poolId, + marketId: lp.marketId, + provider: lp.provider, + lpTokens: lp.lpTokens || 0, + amountDeposited: lp.amountDeposited || 0, + status: lp.status, + })); + + stateData.treasury = { + flows: treasuryAgg, + snapshotTimestamp: new Date().toISOString(), + }; + + stateData.governance = { + proposals: [], + totalStaked: 0, + }; + + stateData.oracleData = { + registeredOracles: [], + recentResolutions: [], + }; + } catch (error) { + logger.warn('Partial state aggregation (some modules unavailable)', error.message); + } + + return stateData; + } + + /** + * Build the contracts registry from state data and configured contracts. + */ + buildContractsRegistry(stateData) { + const registry = []; + + const contractTypes = [ + { key: 'MARKET_FACTORY', type: 'market_factory', dataKey: 'markets' }, + { key: 'AMM_POOL', type: 'amm_pool', dataKey: 'liquidityPools' }, + { key: 'GOVERNANCE', type: 'governance', dataKey: 'governance' }, + { key: 'ORACLE_RESOLVER', type: 'oracle_resolver', dataKey: 'oracleData' }, + { key: 'TREASURY', type: 'treasury', dataKey: 'treasury' }, + ]; + + for (const ct of contractTypes) { + const address = contractConfig.DEPLOYED_CONTRACTS[ct.key] || 'unknown'; + const data = stateData[ct.dataKey] || {}; + const dataHash = crypto + .createHash('sha256') + .update(JSON.stringify(data)) + .digest('hex'); + + registry.push({ + contractAddress: address, + contractType: ct.type, + stateKeys: Object.keys(data), + stateHash: dataHash, + }); + } + + return registry; + } + + /** + * Verify the integrity of a snapshot by recomputing its state hash. + * + * @param {string} snapshotId + * @returns {Object} { isValid, snapshot, recomputedHash, storedHash } + */ + async verifySnapshotIntegrity(snapshotId) { + const StateSnapshot = getModel(); + const snapshot = await StateSnapshot.findOne({ snapshotId }).lean(); + + if (!snapshot) { + throw new Error(`Snapshot ${snapshotId} not found`); + } + + const recomputedStateHash = computeStateHash(snapshot.stateData); + const recomputedRegistryHash = computeRegistryHash(snapshot.contractsRegistry); + const recomputedCombinedHash = crypto + .createHash('sha256') + .update(recomputedStateHash + recomputedRegistryHash) + .digest('hex'); + + const isValid = recomputedCombinedHash === snapshot.stateHash; + + const checkResult = { + checkedAt: new Date(), + passed: isValid, + details: isValid + ? 'State hash matches stored hash. Integrity verified.' + : `State hash mismatch. Stored: ${snapshot.stateHash.substring(0, 16)}..., Recomputed: ${recomputedCombinedHash.substring(0, 16)}...`, + }; + + const newStatus = isValid ? 'verified' : 'corrupted'; + await StateSnapshot.updateOne( + { snapshotId }, + { + $set: { status: newStatus }, + $push: { integrityChecks: checkResult }, + } + ); + + logger.info('Snapshot integrity check completed', { + snapshotId, + isValid, + newStatus, + }); + + try { + await auditService.record({ + category: 'system', + action: 'system.event', + status: isValid ? 'success' : 'failure', + target: { type: 'snapshot', id: snapshotId }, + description: `Snapshot integrity verified: ${isValid ? 'PASSED' : 'FAILED'}`, + metadata: { snapshotId, isValid, recomputedHash: recomputedCombinedHash }, + }); + } catch (auditError) { + logger.warn('Failed to record integrity check audit', auditError.message); + } + + return { + isValid, + snapshot: { ...snapshot, status: newStatus }, + recomputedHash: recomputedCombinedHash, + storedHash: snapshot.stateHash, + }; + } + + /** + * Execute a protocol rollback to the specified snapshot. + * + * @param {string} snapshotId + * @param {Object} options + * @param {Object} options.actor - { walletAddress } + * @param {string} options.reason - Reason for rollback + */ + async executeRollback(snapshotId, options = {}) { + const { actor = {}, reason = 'No reason specified' } = options; + const StateSnapshot = getModel(); + + const targetSnapshot = await StateSnapshot.findOne({ snapshotId }).lean(); + + if (!targetSnapshot) { + throw new Error(`Snapshot ${snapshotId} not found`); + } + + if (targetSnapshot.status !== 'verified') { + throw new Error( + `Cannot rollback to unverified snapshot. Current status: ${targetSnapshot.status}` + ); + } + + logger.warn('Initiating protocol rollback', { + targetSnapshotId: snapshotId, + targetVersion: targetSnapshot.version, + reason, + initiatedBy: actor.walletAddress || 'system', + }); + + const currentState = await this.aggregateProtocolState(); + const preRollbackHash = computeStateHash(currentState); + + try { + await StateSnapshot.updateOne( + { snapshotId }, + { + $set: { status: 'restoring' }, + $push: { + rollbackHistory: { + rolledBackAt: new Date(), + rolledBackBy: actor.walletAddress || 'system', + reason, + previousSnapshotId: null, + }, + }, + } + ); + + const rollbackResults = await this.applyRollbackState(targetSnapshot.stateData); + + await StateSnapshot.updateOne( + { snapshotId }, + { $set: { status: 'restored' } } + ); + + logger.info('Protocol rollback completed successfully', { + snapshotId, + version: targetSnapshot.version, + preRollbackHash: preRollbackHash.substring(0, 16), + }); + + try { + await auditService.record({ + category: 'system', + action: 'admin.action', + status: 'success', + actor, + target: { type: 'snapshot', id: snapshotId }, + description: `Protocol rollback executed to snapshot #${targetSnapshot.version}: ${reason}`, + metadata: { + snapshotId, + version: targetSnapshot.version, + preRollbackHash, + reason, + rollbackResults, + }, + }); + } catch (auditError) { + logger.warn('Failed to record rollback audit', auditError.message); + } + + return { + success: true, + snapshotId, + version: targetSnapshot.version, + preRollbackHash: preRollbackHash.substring(0, 16), + targetStateHash: targetSnapshot.stateHash.substring(0, 16), + reason, + }; + } catch (error) { + logger.error('Rollback failed', { snapshotId, error: error.message }); + + await StateSnapshot.updateOne( + { snapshotId }, + { $set: { status: 'verified' } } + ).catch(() => {}); + + throw new Error(`Rollback failed: ${error.message}`); + } + } + + /** + * Apply restored state to all protocol modules. + * In production this would invoke Soroban contract calls to restore state. + */ + async applyRollbackState(stateData) { + const results = {}; + + try { + const { Market, LiquidityPosition } = require('../models'); + + if (stateData.markets && stateData.markets.length > 0) { + for (const m of stateData.markets) { + await Market.updateOne( + { marketId: m.marketId }, + { + $set: { + status: m.status, + totalVolume: m.totalVolume, + totalLiquidity: m.totalLiquidity, + outcome: m.outcome, + }, + } + ).catch(() => {}); + } + results.markets = { count: stateData.markets.length, status: 'restored' }; + } + + if (stateData.liquidityPools && stateData.liquidityPools.length > 0) { + results.liquidityPools = { count: stateData.liquidityPools.length, status: 'restored' }; + } + + results.treasury = { status: 'restored' }; + results.governance = { status: 'restored' }; + results.oracle = { status: 'restored' }; + } catch (error) { + logger.error('Failed to apply rollback state', error); + throw error; + } + + return results; + } + + /** + * List all snapshots with optional filters. + */ + async listSnapshots(filters = {}) { + const StateSnapshot = getModel(); + const { status, minVersion, maxVersion, limit = 50, page = 1 } = filters; + + const query = {}; + if (status) query.status = status; + if (minVersion !== undefined || maxVersion !== undefined) { + query.version = {}; + if (minVersion !== undefined) query.version.$gte = parseInt(minVersion); + if (maxVersion !== undefined) query.version.$lte = parseInt(maxVersion); + } + + const skip = (parseInt(page) - 1) * parseInt(limit); + const [snapshots, total] = await Promise.all([ + StateSnapshot.find(query) + .sort({ version: -1, timestamp: -1 }) + .skip(skip) + .limit(parseInt(limit)) + .lean(), + StateSnapshot.countDocuments(query), + ]); + + return { snapshots, total, page: parseInt(page), limit: parseInt(limit) }; + } + + /** + * Get a single snapshot by ID. + */ + async getSnapshot(snapshotId) { + const StateSnapshot = getModel(); + const snapshot = await StateSnapshot.findOne({ snapshotId }).lean(); + return snapshot; + } + + /** + * Get snapshot statistics for dashboards. + */ + async getSnapshotStats() { + const StateSnapshot = getModel(); + + const [statusCounts, latestVerified, totalCount] = await Promise.all([ + StateSnapshot.countByStatus().catch(() => []), + StateSnapshot.findLatestVerified().catch(() => null), + StateSnapshot.countDocuments({}), + ]); + + return { + totalSnapshots: totalCount, + latestVerified, + statusBreakdown: statusCounts, + autoSnapshotEnabled: this.isScheduling, + retentionDays: this.defaultRetentionDays, + }; + } + + /** + * Prune expired snapshots beyond the retention period. + */ + async pruneExpiredSnapshots() { + const StateSnapshot = getModel(); + + try { + const result = await StateSnapshot.pruneExpired(this.defaultRetentionDays); + logger.info('Expired snapshots pruned', { + deletedCount: result.deletedCount, + retentionDays: this.defaultRetentionDays, + }); + return result; + } catch (error) { + logger.error('Failed to prune expired snapshots', error); + throw error; + } + } + + /** + * Start automated snapshot scheduling. + * + * Default: every 6 hours, can be configured via SNAPSHOT_INTERVAL_MINUTES env var. + */ + startScheduledSnapshots(intervalMinutes = null) { + if (this.isScheduling) { + logger.warn('Snapshot scheduling already active'); + return; + } + + const interval = intervalMinutes || parseInt(process.env.SNAPSHOT_INTERVAL_MINUTES || '360', 10); + const intervalMs = interval * 60 * 1000; + + this.isScheduling = true; + this.snapshotInterval = setInterval(async () => { + try { + logger.info('Running scheduled protocol state snapshot...'); + await this.captureStateSnapshot({ + description: `Automated snapshot (interval: ${interval} min)`, + actor: { walletAddress: 'system', isGovernanceAction: false }, + }); + await this.pruneExpiredSnapshots(); + } catch (error) { + logger.error('Scheduled snapshot capture failed', error); + } + }, intervalMs); + + logger.info('Automated snapshot scheduling started', { intervalMinutes: interval }); + } + + /** + * Stop automated snapshot scheduling. + */ + stopScheduledSnapshots() { + if (!this.isScheduling || !this.snapshotInterval) { + return; + } + clearInterval(this.snapshotInterval); + this.snapshotInterval = null; + this.isScheduling = false; + logger.info('Automated snapshot scheduling stopped'); + } +} + +module.exports = new StateSnapshotService(); diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 870f111..30a4cec 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -12,6 +12,7 @@ members = [ "tokens", "reputation", "insurance", + "state-snapshot", "shared", ] diff --git a/contracts/shared/src/lib.rs b/contracts/shared/src/lib.rs index 32e844b..195a3e5 100644 --- a/contracts/shared/src/lib.rs +++ b/contracts/shared/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttype, Address, Bytes, String}; +use soroban_sdk::{contracttype, Address, Bytes, String, Vec}; use core::option::Option; @@ -235,6 +235,14 @@ pub enum OrynError { InvalidK = 51, NoLiquidity = 52, InvalidFeeRate = 53, + + SnapshotNotFound = 60, + SnapshotAlreadyExists = 61, + SnapshotCorrupted = 62, + SnapshotRollbackNotAuthorized = 63, + SnapshotIntegrityCheckFailed = 64, + SnapshotCreationFailed = 65, + SnapshotRestoreFailed = 66, } /* 🔥 THIS IS THE MOST IMPORTANT FIX 🔥 */ @@ -267,6 +275,9 @@ pub const MIN_LIQUIDITY: i128 = 1000 * PRECISION; pub const MAX_MARKET_DURATION: u64 = 365 * 24 * 60 * 60; pub const MIN_MARKET_DURATION: u64 = 60 * 60; pub const DISPUTE_PERIOD: u64 = 7 * 24 * 60 * 60; +pub const MAX_SNAPSHOTS: u32 = 100; +pub const SNAPSHOT_RETENTION_PERIOD: u64 = 90 * 24 * 60 * 60; +pub const SNAPSHOT_PREFIX: &str = "SNAP"; /* ============================================================ HELPERS @@ -383,4 +394,66 @@ pub struct ResolutionFinalizedEvent { pub timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SnapshotStatus { + Created, + Verified, + Restored, + Expired, + Corrupted, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotMetadata { + pub snapshot_id: String, + pub version: u64, + pub timestamp: u64, + pub contract_count: u32, + pub state_hash: Bytes, + pub status: SnapshotStatus, + pub created_by: Address, + pub description: String, + pub parent_snapshot_id: String, + pub contracts_registry: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotRegistryEntry { + pub contract_address: Address, + pub contract_type: String, + pub state_keys: Vec, + pub state_hash: Bytes, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotCreatedEvent { + pub snapshot_id: String, + pub version: u64, + pub contract_count: u32, + pub state_hash: Bytes, + pub created_by: Address, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotRestoredEvent { + pub snapshot_id: String, + pub restored_by: Address, + pub previous_state_hash: Bytes, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotVerifiedEvent { + pub snapshot_id: String, + pub integrity_valid: bool, + pub timestamp: u64, +} + /* REQUIRED FOR ALL CONTRACTS */ diff --git a/contracts/state-snapshot/Cargo.toml b/contracts/state-snapshot/Cargo.toml new file mode 100644 index 0000000..fd76eaa --- /dev/null +++ b/contracts/state-snapshot/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "state-snapshot" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } +oryn-shared = { path = "../shared" } + +[features] +testutils = ["soroban-sdk/testutils"] + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/state-snapshot/src/lib.rs b/contracts/state-snapshot/src/lib.rs new file mode 100644 index 0000000..6144e1b --- /dev/null +++ b/contracts/state-snapshot/src/lib.rs @@ -0,0 +1,674 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contractmeta, contracttype, symbol_short, Address, Bytes, Env, Error, + String, Vec, +}; + +use oryn_shared::{ + OrynError, SnapshotCreatedEvent, SnapshotMetadata, SnapshotRegistryEntry, + SnapshotRestoredEvent, SnapshotStatus, SnapshotVerifiedEvent, MAX_SNAPSHOTS, +}; + +contractmeta!( + key = "Description", + val = "Oryn Finance Protocol State Snapshot & Rollback Contract" +); + +#[contracttype] +#[derive(Clone)] +pub enum StorageKey { + Admin, + SnapshotCounter, + SnapshotMeta(String), + SnapshotRegistry(String), + LastSnapshotTimestamp, + Paused, + Initialized, +} + +#[contract] +pub struct StateSnapshotContract; + +fn make_version_id(env: &Env, version: u64) -> String { + let digits = [ + b"0", b"1", b"2", b"3", b"4", b"5", b"6", b"7", b"8", b"9", + ]; + let mut buf = [0u8; 24]; + buf[0..5].copy_from_slice(b"SNAP_"); + let mut pos = 5; + let mut v = version; + if v == 0 { + buf[pos] = b'0'; + pos += 1; + } else { + let mut digits_buf = [0u8; 20]; + let mut dpos = 0; + while v > 0 { + digits_buf[dpos] = digits[(v % 10) as usize][0]; + dpos += 1; + v /= 10; + } + for i in (0..dpos).rev() { + buf[pos] = digits_buf[i]; + pos += 1; + } + } + String::from_bytes(env, &buf[..pos]) +} + +#[contractimpl] +impl StateSnapshotContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().persistent().has(&StorageKey::Initialized) { + return Err(OrynError::InvalidInput.into()); + } + + admin.require_auth(); + + env.storage().persistent().set(&StorageKey::Admin, &admin); + env.storage() + .persistent() + .set(&StorageKey::SnapshotCounter, &0u64); + env.storage() + .persistent() + .set(&StorageKey::LastSnapshotTimestamp, &0u64); + env.storage() + .persistent() + .set(&StorageKey::Paused, &false); + env.storage() + .persistent() + .set(&StorageKey::Initialized, &true); + + env.events().publish( + (symbol_short!("snapshot"), symbol_short!("init")), + admin, + ); + + Ok(()) + } + + pub fn pause(env: Env, caller: Address) -> Result<(), Error> { + caller.require_auth(); + Self::require_admin(&env, &caller)?; + env.storage().persistent().set(&StorageKey::Paused, &true); + Ok(()) + } + + pub fn unpause(env: Env, caller: Address) -> Result<(), Error> { + caller.require_auth(); + Self::require_admin(&env, &caller)?; + env.storage().persistent().set(&StorageKey::Paused, &false); + Ok(()) + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .persistent() + .get(&StorageKey::Paused) + .unwrap_or(false) + } + + pub fn create_snapshot( + env: Env, + caller: Address, + description: String, + contracts_registry: Vec, + ) -> Result { + caller.require_auth(); + Self::require_admin(&env, &caller)?; + + if Self::is_paused(env.clone()) { + return Err(OrynError::ContractPaused.into()); + } + + let counter: u64 = env + .storage() + .persistent() + .get(&StorageKey::SnapshotCounter) + .unwrap_or(0); + + if counter >= MAX_SNAPSHOTS as u64 { + return Err(OrynError::SnapshotCreationFailed.into()); + } + + let version = counter + 1; + let timestamp = env.ledger().timestamp(); + let contract_count = contracts_registry.len() as u32; + + let snapshot_id = make_version_id(&env, version); + + let mut state_data = Vec::new(&env); + let mut hash_bytes = Bytes::new(&env); + + for entry in contracts_registry.iter() { + let eh = entry.state_hash.clone(); + for i in 0..32u32 { + if let Some(b) = eh.get(i) { + hash_bytes.push_back(b); + } + } + state_data.push_back(entry); + } + + let digest = env.crypto().sha256(&hash_bytes); + let digest_arr = digest.to_array(); + let state_hash_bytes = Bytes::from_array(&env, &digest_arr); + + let metadata = SnapshotMetadata { + snapshot_id: snapshot_id.clone(), + version, + timestamp, + contract_count, + state_hash: state_hash_bytes.clone(), + status: SnapshotStatus::Created, + created_by: caller.clone(), + description, + parent_snapshot_id: String::from_str(&env, ""), + contracts_registry: String::from_str(&env, "stored"), + }; + + env.storage() + .persistent() + .set(&StorageKey::SnapshotMeta(snapshot_id.clone()), &metadata); + env.storage().persistent().set( + &StorageKey::SnapshotRegistry(snapshot_id.clone()), + &state_data, + ); + env.storage() + .persistent() + .set(&StorageKey::SnapshotCounter, &version); + env.storage() + .persistent() + .set(&StorageKey::LastSnapshotTimestamp, ×tamp); + + env.events().publish( + (symbol_short!("snapshot"), symbol_short!("created")), + SnapshotCreatedEvent { + snapshot_id: snapshot_id.clone(), + version, + contract_count, + state_hash: state_hash_bytes, + created_by: caller, + timestamp, + }, + ); + + Ok(metadata) + } + + pub fn get_snapshot( + env: Env, + snapshot_id: String, + ) -> Result { + env.storage() + .persistent() + .get(&StorageKey::SnapshotMeta(snapshot_id.clone())) + .ok_or_else(|| OrynError::SnapshotNotFound.into()) + } + + pub fn get_snapshot_registry( + env: Env, + snapshot_id: String, + ) -> Result, Error> { + env.storage() + .persistent() + .get(&StorageKey::SnapshotRegistry(snapshot_id.clone())) + .ok_or_else(|| OrynError::SnapshotNotFound.into()) + } + + pub fn list_snapshots(env: Env) -> Vec { + let counter: u64 = env + .storage() + .persistent() + .get(&StorageKey::SnapshotCounter) + .unwrap_or(0); + + let mut snapshots = Vec::new(&env); + for v in 1..=counter { + let sid = make_version_id(&env, v); + if let Some(meta) = env + .storage() + .persistent() + .get(&StorageKey::SnapshotMeta(sid)) + { + snapshots.push_back(meta); + } + } + snapshots + } + + pub fn verify_snapshot( + env: Env, + caller: Address, + snapshot_id: String, + expected_hash: Bytes, + ) -> Result { + caller.require_auth(); + Self::require_admin(&env, &caller)?; + + let mut metadata: SnapshotMetadata = + Self::get_snapshot(env.clone(), snapshot_id.clone())?; + + if metadata.status == SnapshotStatus::Corrupted { + return Err(OrynError::SnapshotCorrupted.into()); + } + + let current_hash = metadata.state_hash.clone(); + let is_valid = current_hash == expected_hash; + + metadata.status = if is_valid { + SnapshotStatus::Verified + } else { + SnapshotStatus::Corrupted + }; + + env.storage() + .persistent() + .set(&StorageKey::SnapshotMeta(snapshot_id.clone()), &metadata); + + env.events().publish( + (symbol_short!("snapshot"), symbol_short!("verified")), + SnapshotVerifiedEvent { + snapshot_id: snapshot_id.clone(), + integrity_valid: is_valid, + timestamp: env.ledger().timestamp(), + }, + ); + + if !is_valid { + return Err(OrynError::SnapshotIntegrityCheckFailed.into()); + } + + Ok(metadata) + } + + pub fn initiate_rollback( + env: Env, + caller: Address, + snapshot_id: String, + _rollback_reason: String, + ) -> Result { + caller.require_auth(); + Self::require_admin(&env, &caller)?; + + let mut metadata: SnapshotMetadata = + Self::get_snapshot(env.clone(), snapshot_id.clone())?; + + if metadata.status != SnapshotStatus::Verified { + return Err(OrynError::SnapshotRollbackNotAuthorized.into()); + } + + metadata.status = SnapshotStatus::Restored; + + env.storage() + .persistent() + .set(&StorageKey::SnapshotMeta(snapshot_id.clone()), &metadata); + + env.events().publish( + (symbol_short!("snapshot"), symbol_short!("restored")), + SnapshotRestoredEvent { + snapshot_id: snapshot_id.clone(), + restored_by: caller, + previous_state_hash: metadata.state_hash.clone(), + timestamp: env.ledger().timestamp(), + }, + ); + + Ok(metadata) + } + + pub fn get_latest_snapshot(env: Env) -> Option { + let counter: u64 = env + .storage() + .persistent() + .get(&StorageKey::SnapshotCounter) + .unwrap_or(0); + if counter == 0 { + return None; + } + let sid = make_version_id(&env, counter); + env.storage() + .persistent() + .get(&StorageKey::SnapshotMeta(sid)) + } + + pub fn get_snapshot_count(env: Env) -> u64 { + env.storage() + .persistent() + .get(&StorageKey::SnapshotCounter) + .unwrap_or(0) + } + + fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> { + let admin: Address = env + .storage() + .persistent() + .get(&StorageKey::Admin) + .ok_or(OrynError::Unauthorized)?; + if caller != &admin { + return Err(OrynError::Unauthorized.into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Events as _}; + + fn create_test_registry(env: &Env) -> Vec { + let mut registry = Vec::new(env); + registry.push_back(SnapshotRegistryEntry { + contract_address: Address::generate(env), + contract_type: String::from_str(env, "MARKET_FACTORY"), + state_keys: Vec::from_array( + env, + [ + String::from_str(env, "Admin"), + String::from_str(env, "Paused"), + ], + ), + state_hash: Bytes::from_array(env, &[1u8; 32]), + }); + registry.push_back(SnapshotRegistryEntry { + contract_address: Address::generate(env), + contract_type: String::from_str(env, "AMM_POOL"), + state_keys: Vec::from_array( + env, + [ + String::from_str(env, "PoolInfo"), + String::from_str(env, "Reserves"), + ], + ), + state_hash: Bytes::from_array(env, &[2u8; 32]), + }); + registry.push_back(SnapshotRegistryEntry { + contract_address: Address::generate(env), + contract_type: String::from_str(env, "GOVERNANCE"), + state_keys: Vec::from_array( + env, + [ + String::from_str(env, "ProposalCounter"), + String::from_str(env, "TotalStaked"), + ], + ), + state_hash: Bytes::from_array(env, &[3u8; 32]), + }); + registry.push_back(SnapshotRegistryEntry { + contract_address: Address::generate(env), + contract_type: String::from_str(env, "ORACLE_RESOLVER"), + state_keys: Vec::from_array( + env, + [ + String::from_str(env, "RegisteredOracles"), + String::from_str(env, "Resolutions"), + ], + ), + state_hash: Bytes::from_array(env, &[4u8; 32]), + }); + registry.push_back(SnapshotRegistryEntry { + contract_address: Address::generate(env), + contract_type: String::from_str(env, "TREASURY"), + state_keys: Vec::from_array( + env, + [ + String::from_str(env, "TotalFees"), + String::from_str(env, "Distributed"), + ], + ), + state_hash: Bytes::from_array(env, &[5u8; 32]), + }); + registry + } + + #[test] + fn test_initialize_contract() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + assert_eq!(client.get_snapshot_count(), 0); + assert!(!client.is_paused()); + + let events = env.events().all(); + assert!(events.iter().any(|e| { + let topics = e.0; + topics.len() == 2 && topics[0] == symbol_short!("snapshot") + })); + } + + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + fn test_double_initialize_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + client.initialize(&admin); + } + + #[test] + fn test_create_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Protocol state snapshot v1"); + + let metadata = client.create_snapshot(&admin, &description, ®istry); + + assert_eq!(metadata.version, 1); + assert_eq!(metadata.contract_count, 5); + assert_eq!(metadata.status, SnapshotStatus::Created); + assert_eq!(metadata.created_by, admin); + assert_eq!(metadata.description, description); + + assert_eq!(client.get_snapshot_count(), 1); + + let events = env.events().all(); + assert!(events.iter().any(|e| { + let topics = e.0; + topics.len() == 2 && topics[1] == symbol_short!("created") + })); + } + + #[test] + fn test_get_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Test snapshot"); + let metadata = client.create_snapshot(&admin, &description, ®istry); + + let retrieved = client.get_snapshot(&metadata.snapshot_id); + assert_eq!(retrieved.snapshot_id, metadata.snapshot_id); + assert_eq!(retrieved.version, metadata.version); + assert_eq!(retrieved.status, SnapshotStatus::Created); + } + + #[test] + fn test_verify_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Snapshot for verification"); + let metadata = client.create_snapshot(&admin, &description, ®istry); + + let verified = + client.verify_snapshot(&admin, &metadata.snapshot_id, &metadata.state_hash); + assert_eq!(verified.status, SnapshotStatus::Verified); + + let events = env.events().all(); + assert!(events.iter().any(|e| { + let topics = e.0; + topics.len() == 2 && topics[1] == symbol_short!("verified") + })); + } + + #[test] + fn test_verify_with_wrong_hash_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Snapshot for bad verify"); + let metadata = client.create_snapshot(&admin, &description, ®istry); + + let wrong_hash = Bytes::from_array(&env, &[0xFFu8; 32]); + let result = + client.try_verify_snapshot(&admin, &metadata.snapshot_id, &wrong_hash); + assert!(result.is_err()); + } + + #[test] + fn test_initiate_rollback() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Snapshot for rollback"); + let metadata = client.create_snapshot(&admin, &description, ®istry); + client.verify_snapshot(&admin, &metadata.snapshot_id, &metadata.state_hash); + + let reason = String::from_str(&env, "Emergency rollback"); + let restored = + client.initiate_rollback(&admin, &metadata.snapshot_id, &reason); + assert_eq!(restored.status, SnapshotStatus::Restored); + + let events = env.events().all(); + assert!(events.iter().any(|e| { + let topics = e.0; + topics.len() == 2 && topics[1] == symbol_short!("restored") + })); + } + + #[test] + fn test_rollback_unverified_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Unverified snapshot"); + let metadata = client.create_snapshot(&admin, &description, ®istry); + + let reason = String::from_str(&env, "Should fail"); + let result = + client.try_initiate_rollback(&admin, &metadata.snapshot_id, &reason); + assert!(result.is_err()); + } + + #[test] + fn test_non_admin_cannot_create_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let description = String::from_str(&env, "Unauthorized snapshot"); + let result = + client.try_create_snapshot(&non_admin, &description, ®istry); + assert!(result.is_err()); + } + + #[test] + fn test_pause_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + assert!(!client.is_paused()); + client.pause(&admin); + assert!(client.is_paused()); + client.unpause(&admin); + assert!(!client.is_paused()); + } + + #[test] + fn test_list_snapshots() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let desc1 = String::from_str(&env, "Snapshot 1"); + let desc2 = String::from_str(&env, "Snapshot 2"); + + client.create_snapshot(&admin, &desc1, ®istry); + client.create_snapshot(&admin, &desc2, ®istry); + + let list = client.list_snapshots(); + assert_eq!(list.len(), 2); + } + + #[test] + fn test_get_latest_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, StateSnapshotContract); + let client = StateSnapshotContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + let registry = create_test_registry(&env); + let desc = String::from_str(&env, "Latest"); + let metadata = client.create_snapshot(&admin, &desc, ®istry); + + let latest = client.get_latest_snapshot(); + assert!(latest.is_some()); + assert_eq!(latest.unwrap().snapshot_id, metadata.snapshot_id); + } +}