#!/usr/bin/env node /** * Manual Credit Persistence Test Script * * This script manually tests the credit persistence flow: * 1. Connect to relay via WebSocket * 2. Register a node with test public key * 3. Submit a task and complete it * 4. Verify QDAG balance updated * 5. Reconnect and verify balance persisted * * Usage: * node manual-credit-test.cjs [--relay-url ] * * Environment: * RELAY_URL - WebSocket URL (default: ws://localhost:8080) */ const WebSocket = require('ws'); const crypto = require('crypto'); // Configuration const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:8080'; const TEST_PUBLIC_KEY = `manual-test-${crypto.randomBytes(8).toString('hex')}`; // Colors for terminal output const colors = { reset: '\x1b[0m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m', dim: '\x1b[2m', }; function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`); } function logSection(title) { console.log(); log(`${'='.repeat(60)}`, 'cyan'); log(` ${title}`, 'cyan'); log(`${'='.repeat(60)}`, 'cyan'); } function logStep(step, description) { log(`\n[Step ${step}] ${description}`, 'yellow'); } function logSuccess(message) { log(` [SUCCESS] ${message}`, 'green'); } function logError(message) { log(` [ERROR] ${message}`, 'red'); } function logInfo(message) { log(` ${message}`, 'dim'); } // Create WebSocket connection function createConnection(nodeId, publicKey) { return new Promise((resolve, reject) => { const ws = new WebSocket(RELAY_URL); const messages = []; ws.on('open', () => { logInfo(`Connected to ${RELAY_URL}`); // Register with the relay ws.send(JSON.stringify({ type: 'register', nodeId: nodeId, publicKey: publicKey, capabilities: ['compute', 'storage'], version: '0.1.0', })); }); ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); messages.push(msg); if (msg.type === 'welcome') { logSuccess(`Registered as ${nodeId}`); logInfo(`Peers: ${msg.peers?.length || 0}`); resolve({ ws, messages, nodeId, publicKey }); } } catch (e) { // Ignore parse errors } }); ws.on('error', (err) => { logError(`WebSocket error: ${err.message}`); reject(err); }); ws.on('close', (code, reason) => { logInfo(`Connection closed: ${code} ${reason}`); }); // Timeout setTimeout(() => { reject(new Error('Connection timeout')); }, 10000); }); } // Wait for specific message type function waitForMessage(node, type, timeout = 5000) { return new Promise((resolve) => { const startIndex = node.messages.length; const startTime = Date.now(); const check = () => { const newMessages = node.messages.slice(startIndex); const msg = newMessages.find(m => m.type === type); if (msg) { resolve(msg); } else if (Date.now() - startTime > timeout) { resolve(null); } else { setTimeout(check, 50); } }; check(); }); } // Get current QDAG balance async function getBalance(node) { node.ws.send(JSON.stringify({ type: 'ledger_sync', publicKey: node.publicKey, })); const response = await waitForMessage(node, 'ledger_sync_response', 5000); if (response?.ledger) { return { earned: BigInt(response.ledger.earned || '0'), spent: BigInt(response.ledger.spent || '0'), tasksCompleted: response.ledger.tasksCompleted || 0, }; } return { earned: 0n, spent: 0n, tasksCompleted: 0 }; } // Format rUv amount function formatRuv(nanoRuv) { const ruv = Number(nanoRuv) / 1e9; return `${ruv.toFixed(6)} rUv`; } // Main test flow async function runTest() { logSection('Edge-Net Credit Persistence Manual Test'); log(`Relay URL: ${RELAY_URL}`); log(`Test Public Key: ${TEST_PUBLIC_KEY}`); let submitter = null; let worker = null; let initialBalance = null; try { // Step 1: Connect as submitter logStep(1, 'Connect task submitter'); submitter = await createConnection( `submitter-${crypto.randomBytes(4).toString('hex')}`, `submitter-key-${crypto.randomBytes(8).toString('hex')}` ); // Step 2: Connect as worker with test public key logStep(2, 'Connect worker with test identity'); worker = await createConnection( `worker-${crypto.randomBytes(4).toString('hex')}`, TEST_PUBLIC_KEY ); // Step 3: Get initial QDAG balance logStep(3, 'Get initial QDAG balance'); initialBalance = await getBalance(worker); logInfo(`Earned: ${formatRuv(initialBalance.earned)}`); logInfo(`Spent: ${formatRuv(initialBalance.spent)}`); logInfo(`Tasks Completed: ${initialBalance.tasksCompleted}`); logSuccess('Initial balance retrieved'); // Step 4: Submit a task logStep(4, 'Submit a task'); submitter.ws.send(JSON.stringify({ type: 'task_submit', task: { type: 'inference', model: 'test-model', data: 'test-data', maxCredits: '5000000000', // 5 rUv }, })); const acceptance = await waitForMessage(submitter, 'task_accepted', 5000); if (!acceptance) { logError('Task not accepted'); throw new Error('Task submission failed'); } logSuccess(`Task accepted: ${acceptance.taskId}`); // Step 5: Wait for task assignment logStep(5, 'Wait for task assignment'); const assignment = await waitForMessage(worker, 'task_assignment', 5000); if (!assignment) { logError('Task not assigned to worker'); logInfo('This may happen if there are multiple workers - try again'); throw new Error('Task not assigned'); } logSuccess(`Task assigned: ${assignment.task.id}`); // Step 6: Complete the task logStep(6, 'Complete the task'); worker.ws.send(JSON.stringify({ type: 'task_complete', taskId: assignment.task.id, result: { completed: true, model: 'test-model' }, reward: '5000000000', // 5 rUv })); const creditMsg = await waitForMessage(worker, 'credit_earned', 5000); if (!creditMsg) { logError('No credit_earned message received'); throw new Error('Credit not earned'); } const earnedAmount = BigInt(creditMsg.amount || '0'); logSuccess(`Credit earned: ${formatRuv(earnedAmount)}`); // Step 7: Verify QDAG balance updated logStep(7, 'Verify QDAG balance updated'); const afterBalance = await getBalance(worker); logInfo(`New Earned: ${formatRuv(afterBalance.earned)}`); logInfo(`New Tasks: ${afterBalance.tasksCompleted}`); if (afterBalance.earned > initialBalance.earned) { logSuccess('Balance increased correctly!'); } else { logError('Balance did not increase'); } // Step 8: Disconnect worker logStep(8, 'Disconnect worker (simulating refresh)'); worker.ws.close(); await new Promise(resolve => setTimeout(resolve, 1000)); logSuccess('Worker disconnected'); // Step 9: Reconnect with same identity logStep(9, 'Reconnect with same identity'); worker = await createConnection( `worker-reconnect-${crypto.randomBytes(4).toString('hex')}`, TEST_PUBLIC_KEY ); logSuccess('Worker reconnected'); // Step 10: Verify balance persisted logStep(10, 'Verify balance persisted in QDAG'); const persistedBalance = await getBalance(worker); logInfo(`Persisted Earned: ${formatRuv(persistedBalance.earned)}`); logInfo(`Persisted Tasks: ${persistedBalance.tasksCompleted}`); if (persistedBalance.earned === afterBalance.earned) { logSuccess('Balance persisted correctly across reconnection!'); } else { logError(`Balance mismatch: expected ${formatRuv(afterBalance.earned)}, got ${formatRuv(persistedBalance.earned)}`); } // Summary logSection('Test Summary'); const deltaEarned = persistedBalance.earned - initialBalance.earned; log(`Public Key: ${TEST_PUBLIC_KEY}`); log(`Credits Earned This Session: ${formatRuv(deltaEarned)}`); log(`Total Credits: ${formatRuv(persistedBalance.earned)}`); log(`Total Tasks: ${persistedBalance.tasksCompleted}`); if (deltaEarned > 0n && persistedBalance.earned === afterBalance.earned) { logSuccess('\nALL TESTS PASSED!'); log('Credit persistence is working correctly.'); } else { logError('\nSOME TESTS FAILED'); } } catch (error) { logError(`Test failed: ${error.message}`); console.error(error); } finally { // Cleanup if (submitter?.ws?.readyState === WebSocket.OPEN) { submitter.ws.close(); } if (worker?.ws?.readyState === WebSocket.OPEN) { worker.ws.close(); } // Give connections time to close await new Promise(resolve => setTimeout(resolve, 500)); } } // Additional test: Self-reporting prevention async function testSelfReportingPrevention() { logSection('Security Test: Self-Reporting Prevention'); let attacker = null; try { // Connect as attacker logStep(1, 'Connect as attacker'); attacker = await createConnection( `attacker-${crypto.randomBytes(4).toString('hex')}`, `attacker-key-${crypto.randomBytes(8).toString('hex')}` ); // Get initial balance logStep(2, 'Get initial balance'); const initialBalance = await getBalance(attacker); logInfo(`Initial: ${formatRuv(initialBalance.earned)}`); // Attempt to self-report credits logStep(3, 'Attempt to self-report 1000 rUv'); attacker.ws.send(JSON.stringify({ type: 'ledger_update', publicKey: attacker.publicKey, earned: '1000000000000', // 1000 rUv spent: '0', })); const error = await waitForMessage(attacker, 'error', 3000); if (error) { logSuccess(`Attack blocked: ${error.message}`); } else { logError('No error received - attack may have succeeded!'); } // Verify balance unchanged logStep(4, 'Verify balance unchanged'); const finalBalance = await getBalance(attacker); logInfo(`Final: ${formatRuv(finalBalance.earned)}`); if (finalBalance.earned === initialBalance.earned) { logSuccess('Self-reporting attack prevented!'); } else { logError('SECURITY BREACH: Balance was modified!'); } } catch (error) { logError(`Security test failed: ${error.message}`); } finally { if (attacker?.ws?.readyState === WebSocket.OPEN) { attacker.ws.close(); } await new Promise(resolve => setTimeout(resolve, 500)); } } // Additional test: Double-completion prevention async function testDoubleCompletionPrevention() { logSection('Security Test: Double-Completion Prevention'); let submitter = null; let worker = null; try { // Connect nodes logStep(1, 'Connect nodes'); submitter = await createConnection( `submitter-${crypto.randomBytes(4).toString('hex')}`, `submitter-key-${crypto.randomBytes(8).toString('hex')}` ); worker = await createConnection( `worker-${crypto.randomBytes(4).toString('hex')}`, `worker-key-${crypto.randomBytes(8).toString('hex')}` ); // Submit task logStep(2, 'Submit task'); submitter.ws.send(JSON.stringify({ type: 'task_submit', task: { type: 'inference', model: 'test-model', maxCredits: '10000000000', // 10 rUv }, })); const acceptance = await waitForMessage(submitter, 'task_accepted'); if (!acceptance) throw new Error('Task not accepted'); logSuccess(`Task accepted: ${acceptance.taskId}`); // Wait for assignment const assignment = await waitForMessage(worker, 'task_assignment'); if (!assignment) throw new Error('Task not assigned'); const taskId = assignment.task.id; logSuccess(`Task assigned: ${taskId}`); // First completion logStep(3, 'Complete task (first time)'); worker.ws.send(JSON.stringify({ type: 'task_complete', taskId: taskId, result: { done: true }, reward: '10000000000', })); const credit1 = await waitForMessage(worker, 'credit_earned', 3000); if (credit1) { logSuccess(`First completion: earned ${formatRuv(credit1.amount)}`); } else { logError('First completion failed'); } // Second completion (should fail) logStep(4, 'Attempt second completion (should fail)'); await new Promise(resolve => setTimeout(resolve, 500)); const startIdx = worker.messages.length; worker.ws.send(JSON.stringify({ type: 'task_complete', taskId: taskId, result: { done: true }, reward: '10000000000', })); const error = await waitForMessage(worker, 'error', 3000); if (error && error.message.includes('already completed')) { logSuccess(`Double-completion blocked: ${error.message}`); } else { // Check if credit_earned was received (bad) const credit2 = worker.messages.slice(startIdx).find(m => m.type === 'credit_earned'); if (credit2) { logError('SECURITY BREACH: Double-completion earned credits!'); } else { logInfo('No credit earned (expected behavior)'); logSuccess('Double-completion prevented'); } } } catch (error) { logError(`Security test failed: ${error.message}`); } finally { if (submitter?.ws?.readyState === WebSocket.OPEN) submitter.ws.close(); if (worker?.ws?.readyState === WebSocket.OPEN) worker.ws.close(); await new Promise(resolve => setTimeout(resolve, 500)); } } // Run all tests async function main() { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(` Edge-Net Credit Persistence Manual Test Usage: node manual-credit-test.cjs [options] Options: --relay-url WebSocket URL (default: ws://localhost:8080) --security-only Only run security tests --help, -h Show this help Environment Variables: RELAY_URL Alternative to --relay-url `); process.exit(0); } // Override relay URL from args const urlIdx = args.indexOf('--relay-url'); if (urlIdx !== -1 && args[urlIdx + 1]) { process.env.RELAY_URL = args[urlIdx + 1]; } if (args.includes('--security-only')) { await testSelfReportingPrevention(); await testDoubleCompletionPrevention(); } else { await runTest(); await testSelfReportingPrevention(); await testDoubleCompletionPrevention(); } logSection('All Tests Complete'); } main().catch(console.error);