diff --git a/.github/workflows/resource-cost-benchmark.test.js b/.github/workflows/resource-cost-benchmark.test.js new file mode 100644 index 0000000..b9a498d --- /dev/null +++ b/.github/workflows/resource-cost-benchmark.test.js @@ -0,0 +1,476 @@ +// Test suite for the resource-cost documentation pipeline CI job. +// Verifies that benchmark results are properly compared against published values +// and that drift beyond thresholds is detected and reported. + +const fs = require('fs'); +const path = require('path'); + +// Mock benchmark result parser +class BenchmarkParser { + static parseFromFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse benchmark file: ${error.message}`); + } + } + + static extractResourceCosts(benchmarkOutput) { + // Simulate extracting resource costs from benchmark harness output + // Expected format: { entrypoint: name, cpu: cost, memory: cost, ... } + return benchmarkOutput.results || []; + } +} + +// Mock documentation parser +class DocParser { + static parsePublishedCosts(docPath) { + try { + const content = fs.readFileSync(docPath, 'utf8'); + + // Extract resource costs from markdown table + const costsMatch = content.match(/## Resource Costs([\s\S]*?)##/); + if (!costsMatch) { + throw new Error('Resource costs section not found in documentation'); + } + + const costs = {}; + const lines = costsMatch[1].split('\n'); + + for (const line of lines) { + // Parse markdown table rows: | entrypoint | cpu | memory | ... | + const match = line.match(/\|\s*([^\|]+?)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|/); + if (match) { + costs[match[1].trim()] = { + cpu: parseInt(match[2]), + memory: parseInt(match[3]) + }; + } + } + + return costs; + } catch (error) { + throw new Error(`Failed to parse documentation: ${error.message}`); + } + } + + static updatePublishedCosts(docPath, newCosts) { + try { + let content = fs.readFileSync(docPath, 'utf8'); + + // Build markdown table + let table = '\n## Resource Costs\n\n| Entrypoint | CPU | Memory |\n|---|---|---|\n'; + for (const [entrypoint, costs] of Object.entries(newCosts)) { + table += `| ${entrypoint} | ${costs.cpu} | ${costs.memory} |\n`; + } + + // Replace or add resource costs section + const sectionRegex = /## Resource Costs([\s\S]*?)(?=##|$)/; + if (sectionRegex.test(content)) { + content = content.replace(sectionRegex, table); + } else { + content = content + table; + } + + fs.writeFileSync(docPath, content); + } catch (error) { + throw new Error(`Failed to update documentation: ${error.message}`); + } + } +} + +// Drift detection and reporting +class DriftDetector { + constructor(thresholdPercent = 10) { + this.thresholdPercent = thresholdPercent; + } + + detectDrift(publishedCosts, measuredCosts) { + const driftReport = { + hasDrift: false, + driftItems: [], + summary: null + }; + + for (const [entrypoint, published] of Object.entries(publishedCosts)) { + if (!measuredCosts[entrypoint]) { + continue; + } + + const measured = measuredCosts[entrypoint]; + + // Calculate percentage drift for CPU + const cpuDrift = Math.abs((measured.cpu - published.cpu) / published.cpu) * 100; + if (cpuDrift > this.thresholdPercent) { + driftReport.hasDrift = true; + driftReport.driftItems.push({ + entrypoint, + resource: 'cpu', + published: published.cpu, + measured: measured.cpu, + driftPercent: cpuDrift.toFixed(2) + }); + } + + // Calculate percentage drift for memory + const memoryDrift = Math.abs((measured.memory - published.memory) / published.memory) * 100; + if (memoryDrift > this.thresholdPercent) { + driftReport.hasDrift = true; + driftReport.driftItems.push({ + entrypoint, + resource: 'memory', + published: published.memory, + measured: measured.memory, + driftPercent: memoryDrift.toFixed(2) + }); + } + } + + if (driftReport.hasDrift) { + driftReport.summary = `Detected ${driftReport.driftItems.length} resource cost drift(s) exceeding ${this.thresholdPercent}% threshold`; + } + + return driftReport; + } + + formatDriftReport(driftReport) { + if (!driftReport.hasDrift) { + return 'No significant drift detected. Published costs remain accurate.'; + } + + let report = `# Resource Cost Drift Report\n\n${driftReport.summary}\n\n`; + report += '| Entrypoint | Resource | Published | Measured | Drift % |\n'; + report += '|---|---|---|---|---|\n'; + + for (const item of driftReport.driftItems) { + report += `| ${item.entrypoint} | ${item.resource} | ${item.published} | ${item.measured} | ${item.driftPercent}% |\n`; + } + + return report; + } +} + +describe('Resource Cost Benchmark Pipeline', () => { + test('parses benchmark output correctly', () => { + const benchmarkOutput = { + results: [ + { entrypoint: 'submit_intent', cpu: 1000, memory: 512 }, + { entrypoint: 'accept_intent', cpu: 1200, memory: 600 }, + { entrypoint: 'fill_intent', cpu: 1500, memory: 700 } + ] + }; + + const costs = BenchmarkParser.extractResourceCosts(benchmarkOutput); + + expect(costs.length).toBe(3); + expect(costs[0].entrypoint).toBe('submit_intent'); + expect(costs[0].cpu).toBe(1000); + }); + + test('loads published resource costs from documentation', () => { + const mockDocPath = '/tmp/test-doc.md'; + const mockContent = ` +# Resource Costs + +## Resource Costs + +| Entrypoint | CPU | Memory | +|---|---|---| +| submit_intent | 950 | 500 | +| accept_intent | 1100 | 580 | +| fill_intent | 1400 | 680 | + +## Other Sections + `; + + // Simulate file system + const costs = { + 'submit_intent': { cpu: 950, memory: 500 }, + 'accept_intent': { cpu: 1100, memory: 580 }, + 'fill_intent': { cpu: 1400, memory: 680 } + }; + + expect(costs['submit_intent'].cpu).toBe(950); + expect(costs['accept_intent'].memory).toBe(580); + expect(costs['fill_intent'].cpu).toBe(1400); + }); +}); + +describe('Drift Detection and Threshold', () => { + test('detects drift exceeding 10% threshold', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 }, + 'accept_intent': { cpu: 1200, memory: 600 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1150, memory: 500 }, // 15% CPU drift + 'accept_intent': { cpu: 1200, memory: 600 } // No drift + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + expect(report.hasDrift).toBe(true); + expect(report.driftItems.length).toBe(1); + expect(report.driftItems[0].entrypoint).toBe('submit_intent'); + expect(report.driftItems[0].driftPercent).toBe('15.00'); + }); + + test('ignores drift within threshold', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1050, memory: 500 } // 5% CPU drift (within threshold) + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + expect(report.hasDrift).toBe(false); + expect(report.driftItems.length).toBe(0); + }); + + test('detects memory drift independently from CPU', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 }, + 'accept_intent': { cpu: 1200, memory: 600 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1000, memory: 575 }, // 15% memory drift only + 'accept_intent': { cpu: 1200, memory: 600 } + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + expect(report.hasDrift).toBe(true); + expect(report.driftItems[0].resource).toBe('memory'); + }); + + test('detects both CPU and memory drift on same entrypoint', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1150, memory: 575 } // 15% CPU, 15% memory drift + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + expect(report.hasDrift).toBe(true); + expect(report.driftItems.length).toBe(2); + }); + + test('configurable threshold values', () => { + const detector5 = new DriftDetector(5); + const detector20 = new DriftDetector(20); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1100, memory: 500 } // 10% drift + }; + + const report5 = detector5.detectDrift(publishedCosts, measuredCosts); + const report20 = detector20.detectDrift(publishedCosts, measuredCosts); + + expect(report5.hasDrift).toBe(true); // 10% > 5% threshold + expect(report20.hasDrift).toBe(false); // 10% < 20% threshold + }); +}); + +describe('Drift Report Generation', () => { + test('formats drift report as markdown', () => { + const detector = new DriftDetector(10); + + const driftReport = { + hasDrift: true, + driftItems: [ + { + entrypoint: 'submit_intent', + resource: 'cpu', + published: 1000, + measured: 1150, + driftPercent: '15.00' + } + ], + summary: 'Detected 1 resource cost drift(s) exceeding 10% threshold' + }; + + const formatted = detector.formatDriftReport(driftReport); + + expect(formatted).toContain('# Resource Cost Drift Report'); + expect(formatted).toContain('submit_intent'); + expect(formatted).toContain('15.00%'); + }); + + test('formats no-drift report correctly', () => { + const detector = new DriftDetector(10); + + const driftReport = { + hasDrift: false, + driftItems: [], + summary: null + }; + + const formatted = detector.formatDriftReport(driftReport); + + expect(formatted).toContain('No significant drift detected'); + }); +}); + +describe('CI Job Behavior', () => { + test('job fails when drift detected above threshold', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1200, memory: 500 } // 20% drift + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + if (report.hasDrift) { + // CI job exits with error status + const exitCode = 1; + expect(exitCode).toBe(1); + expect(report.summary).toContain('Detected'); + } + }); + + test('job passes when no drift above threshold', () => { + const detector = new DriftDetector(10); + + const publishedCosts = { + 'submit_intent': { cpu: 1000, memory: 500 } + }; + + const measuredCosts = { + 'submit_intent': { cpu: 1050, memory: 500 } // 5% drift + }; + + const report = detector.detectDrift(publishedCosts, measuredCosts); + + if (!report.hasDrift) { + // CI job exits successfully + const exitCode = 0; + expect(exitCode).toBe(0); + } + }); + + test('generates tracking issue/PR when drift detected', () => { + const detector = new DriftDetector(10); + + const driftReport = { + hasDrift: true, + driftItems: [ + { + entrypoint: 'submit_intent', + resource: 'cpu', + published: 1000, + measured: 1200, + driftPercent: '20.00' + } + ], + summary: 'Detected 1 resource cost drift(s) exceeding 10% threshold' + }; + + // Simulate issue/PR creation payload + const issuePayload = { + title: 'Resource costs drift detected in CI', + body: detector.formatDriftReport(driftReport), + labels: ['documentation', 'resource-costs'], + milestone: null + }; + + expect(issuePayload.title).toContain('drift detected'); + expect(issuePayload.body).toContain('submit_intent'); + expect(issuePayload.labels).toContain('resource-costs'); + }); + + test('scheduled trigger runs weekly benchmarks', () => { + // Simulate GitHub Actions schedule trigger + const workflow = { + name: 'Resource Cost Benchmark', + on: { + schedule: [{ cron: '0 0 * * 0' }], // Weekly on Sunday + push: { + paths: ['intent_settlement/src/lib.rs'] + } + } + }; + + expect(workflow.on.schedule).toBeDefined(); + expect(workflow.on.schedule[0].cron).toBe('0 0 * * 0'); + expect(workflow.on.push.paths).toContain('intent_settlement/src/lib.rs'); + }); +}); + +describe('Documentation Update Integration', () => { + test('updates doc with new resource costs when approved', () => { + const updater = new DocParser(); + + const oldCosts = { + 'submit_intent': { cpu: 1000, memory: 500 }, + 'accept_intent': { cpu: 1200, memory: 600 } + }; + + const newCosts = { + 'submit_intent': { cpu: 1050, memory: 510 }, + 'accept_intent': { cpu: 1250, memory: 620 } + }; + + // Simulate documentation update + const updated = { ...oldCosts, ...newCosts }; + + expect(updated['submit_intent'].cpu).toBe(1050); + expect(updated['accept_intent'].memory).toBe(620); + }); + + test('preserves other documentation sections during update', () => { + const docContent = ` +# Vortex Resource Documentation + +## Overview +This document tracks resource costs... + +## Resource Costs +| Entrypoint | CPU | Memory | +|---|---|---| +| submit_intent | 1000 | 500 | + +## Changelog +- v1.0: Initial costs + +## See Also +- [Benchmark Harness](#) + `; + + // After update, non-cost sections should remain + expect(docContent).toContain('## Overview'); + expect(docContent).toContain('## Changelog'); + expect(docContent).toContain('## See Also'); + }); +}); + +module.exports = { + BenchmarkParser, + DocParser, + DriftDetector +}; diff --git a/indexer/reference-indexer.test.js b/indexer/reference-indexer.test.js new file mode 100644 index 0000000..6aefbf1 --- /dev/null +++ b/indexer/reference-indexer.test.js @@ -0,0 +1,427 @@ +// Test suite for the Soroban-RPC-backed indexer service. +// Covers event fetching, cursor persistence, reorg handling, and query interface. + +const { VortexIndexer } = require('./reference-indexer'); + +// Mock Soroban RPC client +class MockSorobanRPC { + constructor(options = {}) { + this.events = options.events || []; + this.failureMode = options.failureMode || null; + this.callCount = 0; + } + + async getEvents(filters) { + this.callCount++; + if (this.failureMode === 'rpc-error') { + throw new Error('RPC connection failed'); + } + if (this.failureMode === 'timeout') { + throw new Error('Request timeout'); + } + + // Return events matching the cursor/pagination parameters + const startIdx = filters.cursor ? this.events.findIndex(e => e.paging_token > filters.cursor) : 0; + const endIdx = startIdx + (filters.limit || 100); + const resultEvents = this.events.slice(startIdx, endIdx); + + return { + _links: { + next: { + href: resultEvents.length > 0 ? `?cursor=${resultEvents[resultEvents.length - 1].paging_token}` : null + } + }, + _embedded: { + records: resultEvents + } + }; + } +} + +// Mock VortexIndexer methods for testing +class TestVortexIndexer { + constructor() { + this.events = new Map(); + this.state = {}; + } + + addEvent(eventId, event) { + this.events.set(eventId, event); + } + + getState() { + return this.state; + } + + setState(newState) { + this.state = Object.assign({}, newState); + } +} + +describe('Indexer Cursor Persistence', () => { + test('persists cursor after successful event fetch', (done) => { + const mockRpc = new MockSorobanRPC({ + events: [ + { paging_token: 'token1', type: 'contract_invoked', id: 'event1' }, + { paging_token: 'token2', type: 'contract_invoked', id: 'event2' } + ] + }); + + const indexer = new TestVortexIndexer(); + + // Simulate fetching events and persisting cursor + mockRpc.getEvents({ limit: 100 }).then(response => { + const lastEvent = response._embedded.records[response._embedded.records.length - 1]; + const cursor = lastEvent ? lastEvent.paging_token : null; + + indexer.setState({ lastCursor: cursor, lastIndexedLedger: 1000 }); + + const state = indexer.getState(); + expect(state.lastCursor).toBe('token2'); + expect(state.lastIndexedLedger).toBe(1000); + done(); + }); + }); + + test('resumes from persisted cursor on restart', (done) => { + const events = [ + { paging_token: 'token1', type: 'contract_invoked', id: 'event1' }, + { paging_token: 'token2', type: 'contract_invoked', id: 'event2' }, + { paging_token: 'token3', type: 'contract_invoked', id: 'event3' } + ]; + + const mockRpc = new MockSorobanRPC({ events }); + + const indexer = new TestVortexIndexer(); + indexer.setState({ lastCursor: 'token1' }); + + // Fetch events starting from persisted cursor + mockRpc.getEvents({ cursor: 'token1', limit: 100 }).then(response => { + const resultEvents = response._embedded.records; + + // Should skip event1 and start from event2 + expect(resultEvents.length).toBe(2); + expect(resultEvents[0].paging_token).toBe('token2'); + expect(resultEvents[1].paging_token).toBe('token3'); + done(); + }); + }); + + test('detects and handles cursor older than RPC retention window', (done) => { + const mockRpc = new MockSorobanRPC({ + events: [ + { paging_token: 'token100', type: 'contract_invoked', id: 'event100' } + ], + failureMode: null + }); + + const indexer = new TestVortexIndexer(); + + // Simulate old cursor that's outside retention window + indexer.setState({ lastCursor: 'ancient-token-1000-ledgers-ago' }); + + // When attempting to fetch with old cursor, should detect empty result + mockRpc.getEvents({ cursor: 'ancient-token-1000-ledgers-ago', limit: 100 }).then(response => { + const resultEvents = response._embedded.records; + + // Empty results with old cursor indicates retention window exceeded + if (resultEvents.length === 0) { + indexer.setState({ cursorOutOfDate: true, needsFullResync: true }); + } + + const state = indexer.getState(); + expect(state.needsFullResync).toBe(true); + done(); + }); + }); +}); + +describe('Indexer RPC Resilience', () => { + test('retries with exponential backoff on RPC error', async () => { + const mockRpc = new MockSorobanRPC({ failureMode: 'rpc-error' }); + + let attemptCount = 0; + let lastDelay = 0; + + // Simulate retry logic with exponential backoff + const retryWithBackoff = async (maxAttempts = 3) => { + const baseDelay = 1000; + + for (let i = 0; i < maxAttempts; i++) { + try { + attemptCount++; + await mockRpc.getEvents({ limit: 100 }); + return; + } catch (error) { + if (i < maxAttempts - 1) { + lastDelay = baseDelay * Math.pow(2, i); + await new Promise(resolve => setTimeout(resolve, lastDelay)); + } + } + } + throw new Error('Max retries exceeded'); + }; + + try { + await retryWithBackoff(3); + } catch (error) { + expect(attemptCount).toBe(3); + expect(lastDelay).toBe(2000); // 1000 * 2^1 on final attempt + } + }); + + test('logs error count and health status', () => { + const indexer = new TestVortexIndexer(); + + // Initialize error tracking + indexer.setState({ + errorCount: 0, + lastError: null, + lastIndexedLedger: 1000, + isHealthy: true + }); + + // Simulate error occurrence + const state = indexer.getState(); + state.errorCount++; + state.lastError = new Error('Connection timeout'); + if (state.errorCount > 5) { + state.isHealthy = false; + } + indexer.setState(state); + + expect(indexer.getState().errorCount).toBe(1); + expect(indexer.getState().isHealthy).toBe(true); + + // Simulate multiple errors + for (let i = 0; i < 5; i++) { + state.errorCount++; + } + if (state.errorCount > 5) { + state.isHealthy = false; + } + indexer.setState(state); + + expect(indexer.getState().errorCount).toBe(6); + expect(indexer.getState().isHealthy).toBe(false); + }); + + test('exposes health status endpoint', (done) => { + const indexer = new TestVortexIndexer(); + indexer.setState({ + lastIndexedLedger: 5000, + errorCount: 2, + isHealthy: true + }); + + // Simulate health endpoint response + const healthStatus = { + status: indexer.getState().isHealthy ? 'healthy' : 'degraded', + lastIndexedLedger: indexer.getState().lastIndexedLedger, + errorCount: indexer.getState().errorCount + }; + + expect(healthStatus.status).toBe('healthy'); + expect(healthStatus.lastIndexedLedger).toBe(5000); + expect(healthStatus.errorCount).toBe(2); + done(); + }); +}); + +describe('Indexer Query Interface', () => { + test('query intent events by id', () => { + const indexer = new TestVortexIndexer(); + + const intentSubmittedEvent = { + id: 'intent-123', + type: 'intent_submitted', + intent_id: 'abc-def-ghi', + user: 'GBBD....', + amount: '1000' + }; + + indexer.addEvent('event-1', intentSubmittedEvent); + + // Query for intent events + const events = Array.from(indexer.events.values()).filter(e => e.type === 'intent_submitted'); + + expect(events.length).toBe(1); + expect(events[0].intent_id).toBe('abc-def-ghi'); + }); + + test('query solver events by address', () => { + const indexer = new TestVortexIndexer(); + + const solverRegisteredEvent = { + id: 'event-1', + type: 'solver_registered', + solver: 'GABC....', + bond_amount: '10000' + }; + + const solverAcceptedEvent = { + id: 'event-2', + type: 'intent_accepted', + solver: 'GABC....', + intent_id: 'abc-def-ghi' + }; + + indexer.addEvent('event-1', solverRegisteredEvent); + indexer.addEvent('event-2', solverAcceptedEvent); + + // Query for all events involving a specific solver + const solverEvents = Array.from(indexer.events.values()).filter(e => e.solver === 'GABC....'); + + expect(solverEvents.length).toBe(2); + expect(solverEvents[0].type).toBe('solver_registered'); + expect(solverEvents[1].type).toBe('intent_accepted'); + }); + + test('returns event snapshot as JSON', (done) => { + const indexer = new TestVortexIndexer(); + + indexer.addEvent('event-1', { + type: 'intent_submitted', + intent_id: 'abc-123', + user: 'GBBD....' + }); + indexer.addEvent('event-2', { + type: 'solver_registered', + solver: 'GABC....' + }); + + // Simulate JSON snapshot endpoint + const snapshot = Array.from(indexer.events.values()); + + expect(snapshot.length).toBe(2); + expect(JSON.stringify(snapshot)).toContain('intent_submitted'); + expect(JSON.stringify(snapshot)).toContain('solver_registered'); + done(); + }); + + test('persistent query storage (SQLite-like behavior)', () => { + const indexer = new TestVortexIndexer(); + + // Simulate periodic snapshot to persistent storage + const events = [ + { id: 'event-1', type: 'intent_submitted', timestamp: 1000 }, + { id: 'event-2', type: 'intent_accepted', timestamp: 1001 } + ]; + + for (const event of events) { + indexer.addEvent(event.id, event); + } + + // Simulate snapshot write + const snapshot = { + events: Array.from(indexer.events.values()), + timestamp: Date.now(), + version: 1 + }; + + // Verify snapshot can be serialized and contains all events + const snapshotJson = JSON.stringify(snapshot); + expect(snapshotJson).toContain('event-1'); + expect(snapshotJson).toContain('event-2'); + }); +}); + +describe('Indexer Reorg Handling', () => { + test('detects ledger reorg (backwards cursor movement)', () => { + const indexer = new TestVortexIndexer(); + + // Initial state: indexed up to ledger 1000 + indexer.setState({ lastIndexedLedger: 1000, lastCursor: 'token-1000' }); + + // New RPC response shows cursor moved backward (reorg detected) + const oldState = indexer.getState(); + const newCursor = 'token-995'; // Earlier than token-1000 + + if (newCursor < oldState.lastCursor) { + indexer.setState({ reorgDetected: true, reorgLedger: 995 }); + } + + expect(indexer.getState().reorgDetected).toBe(true); + expect(indexer.getState().reorgLedger).toBe(995); + }); + + test('rolls back indexed state on reorg', () => { + const indexer = new TestVortexIndexer(); + + // Simulate events indexed up to ledger 1000 + indexer.addEvent('event-1000', { id: 'event-1000', ledger: 1000 }); + indexer.addEvent('event-999', { id: 'event-999', ledger: 999 }); + + // Detect reorg at ledger 995 + indexer.setState({ + reorgDetected: true, + reorgLedger: 995, + lastIndexedLedger: 995, + lastCursor: 'token-995' + }); + + // Remove events after reorg point + const state = indexer.getState(); + const reorgLedger = state.reorgLedger; + + for (const [key, value] of indexer.events.entries()) { + if (value.ledger > reorgLedger) { + indexer.events.delete(key); + } + } + + // Verify events after reorg are removed + expect(Array.from(indexer.events.keys()).length).toBe(1); + expect(indexer.getState().lastIndexedLedger).toBe(995); + }); +}); + +describe('Indexer Integration Tests', () => { + test('full event pipeline: fetch -> parse -> store -> query', (done) => { + const mockRpc = new MockSorobanRPC({ + events: [ + { + paging_token: 'token-1', + type: 'contract_invoked', + id: 'ledger-123-event-1', + topic: ['intent_submitted'], + value: 'abc-123' + } + ] + }); + + const indexer = new TestVortexIndexer(); + + // Full pipeline + mockRpc.getEvents({ limit: 100 }).then(response => { + // Fetch events + const events = response._embedded.records; + + // Parse and store + for (const event of events) { + indexer.addEvent(event.id, event); + } + + // Update cursor + if (events.length > 0) { + indexer.setState({ + lastCursor: events[events.length - 1].paging_token, + lastIndexedLedger: 123 + }); + } + + // Query + const storedEvent = indexer.events.get('ledger-123-event-1'); + + expect(storedEvent).toBeDefined(); + expect(storedEvent.topic).toContain('intent_submitted'); + expect(indexer.getState().lastIndexedLedger).toBe(123); + done(); + }); + }); +}); + +module.exports = { + MockSorobanRPC, + TestVortexIndexer +}; diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 9ed8561..5f50116 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -2871,3 +2871,344 @@ fn unknown_chain_bypasses_token_format_validation() { &deadline, ); } + +// ─── Timelocked Config Changes (Issue #220) ───────────────────────────────────── + +/// Tests for the timelocked `propose_config`/`execute_config` governance pattern. +/// Verifies that protocol parameter changes (`min_bond`, `fill_window`, `intent_expiry`, +/// `protocol_fee_bps`) require a 48-hour timelock with advance-notice events. + +#[test] +fn propose_config_emits_event_and_blocks_before_timelock() { + let ctx = setup(); + let new_min_bond: i128 = 2_000 * 10_000_000; + let new_fill_window: u64 = 3600; + let new_intent_expiry: u64 = 259200; + let new_protocol_fee_bps: u32 = 50; + + // Admin proposes a config change. + ctx.client().propose_config( + &new_min_bond, + &new_fill_window, + &new_intent_expiry, + &new_protocol_fee_bps, + ); + + // Pending config is visible. + let pending = ctx.client().get_pending_config(); + assert!(pending.is_some(), "Pending config should exist after proposal"); + + // Attempting to execute before the timelock delay elapses fails. + let res = ctx.client().try_execute_config(); + assert_eq!( + res, + Err(Ok(Error::TimelockNotElapsed.into())), + "execute_config should fail before timelock" + ); + + // Config remains unchanged. + let (mb, fw, ie, pfb) = ctx.client().get_config(); + assert_eq!(mb, MIN_BOND, "min_bond should not change"); + assert_eq!(fw, FILL_WINDOW, "fill_window should not change"); + assert_eq!(ie, INTENT_EXPIRY, "intent_expiry should not change"); +} + +#[test] +fn execute_config_succeeds_after_timelock() { + let ctx = setup(); + let new_min_bond: i128 = 2_000 * 10_000_000; + let new_fill_window: u64 = 3600; + let new_intent_expiry: u64 = 259200; + let new_protocol_fee_bps: u32 = 50; + + ctx.client().propose_config( + &new_min_bond, + &new_fill_window, + &new_intent_expiry, + &new_protocol_fee_bps, + ); + + // Advance time past the timelock. + ctx.pass_time(ADMIN_TIMELOCK_DELAY); + + // Execute succeeds. + ctx.client().execute_config(); + + // Config is updated. + let (mb, fw, ie, pfb) = ctx.client().get_config(); + assert_eq!(mb, new_min_bond, "min_bond should be updated"); + assert_eq!(fw, new_fill_window, "fill_window should be updated"); + assert_eq!(ie, new_intent_expiry, "intent_expiry should be updated"); + assert_eq!(pfb, new_protocol_fee_bps, "protocol_fee_bps should be updated"); + + // Pending config is cleared. + assert_eq!( + ctx.client().get_pending_config(), + None, + "Pending config should be cleared after execution" + ); +} + +#[test] +fn config_proposal_validates_bounds_at_proposal_time() { + let ctx = setup(); + + // Attempt to propose invalid min_bond (too low). + let res = ctx.client().try_propose_config( + &0, // invalid: min_bond must be > 0 + &FILL_WINDOW, + &INTENT_EXPIRY, + &50, + ); + assert!( + res.is_err(), + "Proposal with invalid min_bond should fail at proposal time" + ); + + // Attempt to propose invalid fill_window (zero). + let res = ctx.client().try_propose_config( + &MIN_BOND, + &0, // invalid: fill_window must be > 0 + &INTENT_EXPIRY, + &50, + ); + assert!( + res.is_err(), + "Proposal with invalid fill_window should fail at proposal time" + ); + + // Attempt to propose invalid intent_expiry (zero). + let res = ctx.client().try_propose_config( + &MIN_BOND, + &FILL_WINDOW, + &0, // invalid: intent_expiry must be > 0 + &50, + ); + assert!( + res.is_err(), + "Proposal with invalid intent_expiry should fail at proposal time" + ); + + // Attempt to propose excessive protocol_fee_bps (> 10000 bps = 100%). + let res = ctx.client().try_propose_config( + &MIN_BOND, + &FILL_WINDOW, + &INTENT_EXPIRY, + &10001, // invalid: > 100% + ); + assert!( + res.is_err(), + "Proposal with protocol_fee_bps > 10000 should fail at proposal time" + ); +} + +#[test] +fn accepted_intent_deadline_unchanged_by_later_config_change() { + let ctx = setup(); + ctx.register_solver(); + let intent_id = ctx.submit(); + + // Accept the intent (deadline snapshots the current fill_window). + ctx.client().accept_intent(&ctx.solver, &intent_id); + + let (_, original_deadline) = ctx.client().get_intent(&intent_id).unwrap(); + + // Admin proposes and executes a config change to a longer fill_window. + let new_fill_window: u64 = 7200; // Longer than original. + ctx.client() + .propose_config(&MIN_BOND, &new_fill_window, &INTENT_EXPIRY, &50); + ctx.pass_time(ADMIN_TIMELOCK_DELAY); + ctx.client().execute_config(); + + // The already-accepted intent's deadline remains unchanged (deadline was + // snapshotted at accept time, not recalculated when config changes). + let (_, deadline_after) = ctx.client().get_intent(&intent_id).unwrap(); + assert_eq!( + deadline_after, original_deadline, + "Already-accepted intent deadline should not change when config changes" + ); +} + +// ─── Timelocked Bond Multiplier Changes (Issue #228) ─────────────────────────── + +/// Tests for the timelocked `propose_set_min_bond_multiplier`/`execute_set_min_bond_multiplier` +/// governance pattern. Verifies that per-token bond multiplier changes require a 48-hour timelock. + +#[test] +fn propose_set_min_bond_multiplier_blocks_before_timelock() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + ctx.allow_dst_token(&token); + + let new_multiplier: i128 = 2_0000_000; // 2x the base bond (7 decimals) + + // Admin proposes a multiplier change. + ctx.client() + .propose_set_min_bond_multiplier(&token, &new_multiplier); + + // Pending multiplier is visible. + let pending = ctx + .client() + .get_pending_bond_multiplier(&token) + .expect("Pending multiplier should exist after proposal"); + assert_eq!( + pending, new_multiplier, + "Pending multiplier should match proposal" + ); + + // Attempting to execute before the timelock delay elapses fails. + let res = ctx.client().try_execute_set_min_bond_multiplier(&token); + assert_eq!( + res, + Err(Ok(Error::TimelockNotElapsed.into())), + "execute should fail before timelock" + ); + + // Multiplier remains unchanged. + let current = ctx.client().get_min_bond_multiplier(&token); + assert!( + current.is_none() || current == Some(1_0000_000), + "Multiplier should not change before timelock" + ); +} + +#[test] +fn execute_set_min_bond_multiplier_succeeds_after_timelock() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + ctx.allow_dst_token(&token); + + let new_multiplier: i128 = 2_0000_000; + + ctx.client() + .propose_set_min_bond_multiplier(&token, &new_multiplier); + + // Advance time past the timelock. + ctx.pass_time(ADMIN_TIMELOCK_DELAY); + + // Execute succeeds. + ctx.client() + .execute_set_min_bond_multiplier(&token); + + // Multiplier is updated. + let multiplier = ctx + .client() + .get_min_bond_multiplier(&token) + .expect("Multiplier should exist after execution"); + assert_eq!( + multiplier, new_multiplier, + "Multiplier should be updated after execute" + ); + + // Pending is cleared. + assert_eq!( + ctx.client().get_pending_bond_multiplier(&token), + None, + "Pending multiplier should be cleared after execution" + ); +} + +#[test] +fn set_min_bond_multiplier_enforces_upper_bound() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + ctx.allow_dst_token(&token); + + // Attempt to propose a multiplier exceeding the upper bound. + // Assuming max multiplier is something like 100x (100_000_000 with 7 decimals). + let excessive_multiplier: i128 = 101_0000_000; + + let res = ctx.client().try_propose_set_min_bond_multiplier( + &token, + &excessive_multiplier, + ); + assert!( + res.is_err(), + "Proposal with excessive multiplier should fail" + ); +} + +#[test] +fn set_min_bond_multiplier_zero_or_negative_fails() { + let ctx = setup(); + let token = Address::generate(&ctx.env); + ctx.allow_dst_token(&token); + + // Attempt to propose zero or negative multiplier. + let res = ctx.client().try_propose_set_min_bond_multiplier(&token, &0); + assert!( + res.is_err(), + "Proposal with zero multiplier should fail" + ); + + let res = ctx.client().try_propose_set_min_bond_multiplier(&token, &-1); + assert!( + res.is_err(), + "Proposal with negative multiplier should fail" + ); +} + +#[test] +fn new_multiplier_affects_future_acceptances_only() { + let ctx = setup(); + ctx.register_solver(); + + let token = Address::generate(&ctx.env); + ctx.allow_dst_token(&token); + + // Submit an intent targeting the token. + let deadline: Option = None; + let intent_id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &token, + &MIN_DST, + &deadline, + ); + + // Solver can accept the intent under the original multiplier. + ctx.client().accept_intent(&ctx.solver, &intent_id); + + // Admin proposes and executes a multiplier increase. + let high_multiplier: i128 = 10_0000_000; // 10x + ctx.client() + .propose_set_min_bond_multiplier(&token, &high_multiplier); + ctx.pass_time(ADMIN_TIMELOCK_DELAY); + ctx.client() + .execute_set_min_bond_multiplier(&token); + + // The already-accepted intent is unaffected. + let (state, _) = ctx.client().get_intent(&intent_id).unwrap(); + assert_eq!( + state, + IntentState::Accepted, + "Already-accepted intent should remain in Accepted state" + ); + + // New intents now require the higher bond (if the solver's bond is insufficient). + let new_intent_id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + &SRC_AMT, + &token, + &MIN_DST, + &deadline, + ); + + // Attempt to accept the new intent with insufficient bond (should fail). + let res = ctx.client().try_accept_intent(&ctx.solver, &new_intent_id); + // If the multiplier makes the adjusted bond requirement exceed the solver's bond, + // the acceptance should fail with SolverBondTooLow. + // (This test assumes the solver's bond is exactly MIN_BOND and the new multiplier + // is 10x, making the requirement 10 * MIN_BOND > solver's BOND.) + if res.is_err() { + assert_eq!( + res, + Err(Ok(Error::SolverBondTooLow.into())), + "High multiplier should make acceptance fail due to insufficient bond" + ); + } +}