822 lines
26 KiB
TypeScript
822 lines
26 KiB
TypeScript
/**
|
|
* Credit Persistence Test Suite
|
|
* Tests for verifying credit persistence works correctly with QDAG
|
|
*
|
|
* Credit Flow Architecture:
|
|
* 1. Credits are earned via task completions (relay server awards them)
|
|
* 2. Local credits are cached in IndexedDB as backup
|
|
* 3. QDAG (Firestore) is the source of truth for credit balance
|
|
* 4. On reconnect, QDAG balance is loaded and replaces local state
|
|
* 5. Same publicKey = same credits across all devices/CLI
|
|
*
|
|
* Test Scenarios:
|
|
* - Test 1: Credits earned locally persist in IndexedDB
|
|
* - Test 2: Credits sync to QDAG after task completion
|
|
* - Test 3: After refresh, QDAG balance is loaded
|
|
* - Test 4: Pending credits shown correctly before sync
|
|
* - Test 5: Double-sync prevention works
|
|
* - Test 6: Rate limiting prevents abuse
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, jest } from '@jest/globals';
|
|
import WebSocket from 'ws';
|
|
import { randomBytes } from 'crypto';
|
|
|
|
// Test configuration
|
|
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:8080';
|
|
const TEST_TIMEOUT = 15000;
|
|
|
|
interface RelayMessage {
|
|
type: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface TestNode {
|
|
ws: WebSocket;
|
|
nodeId: string;
|
|
publicKey: string;
|
|
messages: RelayMessage[];
|
|
}
|
|
|
|
interface LedgerBalance {
|
|
earned: bigint;
|
|
spent: bigint;
|
|
tasksCompleted: number;
|
|
}
|
|
|
|
// Helper to create a test node connection
|
|
async function createTestNode(nodeId?: string, publicKey?: string): Promise<TestNode> {
|
|
const id = nodeId || `test-node-${randomBytes(8).toString('hex')}`;
|
|
const pubKey = publicKey || randomBytes(32).toString('hex');
|
|
|
|
const ws = new WebSocket(RELAY_URL);
|
|
const messages: RelayMessage[] = [];
|
|
|
|
// Collect all messages
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const msg = JSON.parse(data.toString());
|
|
messages.push(msg);
|
|
} catch (e) {
|
|
console.error('Failed to parse message:', e);
|
|
}
|
|
});
|
|
|
|
// Wait for connection
|
|
await new Promise<void>((resolve, reject) => {
|
|
ws.once('open', () => resolve());
|
|
ws.once('error', reject);
|
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
|
});
|
|
|
|
// Register node with public key
|
|
ws.send(JSON.stringify({
|
|
type: 'register',
|
|
nodeId: id,
|
|
publicKey: pubKey,
|
|
capabilities: ['compute', 'storage'],
|
|
version: '0.1.0',
|
|
}));
|
|
|
|
// Wait for welcome message
|
|
await new Promise<void>((resolve) => {
|
|
const checkWelcome = () => {
|
|
if (messages.some(m => m.type === 'welcome')) {
|
|
resolve();
|
|
} else {
|
|
setTimeout(checkWelcome, 50);
|
|
}
|
|
};
|
|
checkWelcome();
|
|
});
|
|
|
|
return { ws, nodeId: id, publicKey: pubKey, messages };
|
|
}
|
|
|
|
// Helper to wait for specific message type
|
|
async function waitForMessage(node: TestNode, type: string, timeout = 5000): Promise<RelayMessage | null> {
|
|
const startTime = Date.now();
|
|
|
|
return new Promise((resolve) => {
|
|
const check = () => {
|
|
const msg = node.messages.find(m => m.type === type);
|
|
if (msg) {
|
|
resolve(msg);
|
|
} else if (Date.now() - startTime > timeout) {
|
|
resolve(null);
|
|
} else {
|
|
setTimeout(check, 50);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
|
|
// Helper to wait for latest message of type (after clearing previous ones)
|
|
async function waitForLatestMessage(node: TestNode, type: string, timeout = 5000): Promise<RelayMessage | null> {
|
|
const startIndex = node.messages.length;
|
|
const startTime = Date.now();
|
|
|
|
return new Promise((resolve) => {
|
|
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();
|
|
});
|
|
}
|
|
|
|
// Helper to get current QDAG balance
|
|
async function getQDAGBalance(node: TestNode): Promise<LedgerBalance> {
|
|
const startIndex = node.messages.length;
|
|
|
|
node.ws.send(JSON.stringify({
|
|
type: 'ledger_sync',
|
|
publicKey: node.publicKey,
|
|
}));
|
|
|
|
const response = await waitForLatestMessage(node, 'ledger_sync_response', 3000);
|
|
|
|
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 };
|
|
}
|
|
|
|
// Helper to submit and complete a task
|
|
async function submitAndCompleteTask(
|
|
submitter: TestNode,
|
|
worker: TestNode,
|
|
maxCredits: bigint = 1000000n
|
|
): Promise<{ taskId: string; reward: bigint } | null> {
|
|
// Submit task
|
|
submitter.ws.send(JSON.stringify({
|
|
type: 'task_submit',
|
|
task: {
|
|
type: 'inference',
|
|
model: 'test-model',
|
|
maxCredits: maxCredits.toString(),
|
|
},
|
|
}));
|
|
|
|
// Wait for task acceptance
|
|
const acceptance = await waitForMessage(submitter, 'task_accepted', 3000);
|
|
if (!acceptance) {
|
|
console.error('Task not accepted');
|
|
return null;
|
|
}
|
|
|
|
const taskId = acceptance.taskId;
|
|
|
|
// Wait for task assignment to worker
|
|
const assignment = await waitForMessage(worker, 'task_assignment', 3000);
|
|
if (!assignment || assignment.task?.id !== taskId) {
|
|
console.error('Task not assigned to worker');
|
|
return null;
|
|
}
|
|
|
|
// Clear worker messages before completion
|
|
const workerMsgIndex = worker.messages.length;
|
|
|
|
// Worker completes task
|
|
worker.ws.send(JSON.stringify({
|
|
type: 'task_complete',
|
|
taskId: taskId,
|
|
result: { completed: true, model: 'test-model' },
|
|
reward: maxCredits.toString(),
|
|
}));
|
|
|
|
// Wait for credit earned confirmation
|
|
const creditMsg = await waitForLatestMessage(worker, 'credit_earned', 3000);
|
|
if (!creditMsg) {
|
|
console.error('No credit_earned message received');
|
|
return null;
|
|
}
|
|
|
|
const reward = BigInt(creditMsg.amount || '0');
|
|
return { taskId, reward };
|
|
}
|
|
|
|
// Helper to close node connection
|
|
function closeNode(node: TestNode): void {
|
|
if (node.ws.readyState === WebSocket.OPEN) {
|
|
node.ws.close();
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TEST SUITE: Credit Persistence
|
|
// =============================================================================
|
|
|
|
describe('Credit Persistence Tests', () => {
|
|
|
|
describe('Test 1: Credits earned locally persist in IndexedDB (via QDAG)', () => {
|
|
it('should persist credits after task completion', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
// Get initial QDAG balance
|
|
const initialBalance = await getQDAGBalance(worker);
|
|
console.log('[Test 1] Initial balance:', initialBalance.earned.toString());
|
|
|
|
// Complete a task to earn credits
|
|
const result = await submitAndCompleteTask(submitter, worker, 2000000n);
|
|
expect(result).toBeTruthy();
|
|
expect(result!.taskId).toBeDefined();
|
|
expect(result!.reward).toBeGreaterThan(0n);
|
|
|
|
// Verify QDAG balance increased
|
|
const newBalance = await getQDAGBalance(worker);
|
|
console.log('[Test 1] New balance:', newBalance.earned.toString());
|
|
|
|
expect(newBalance.earned).toBeGreaterThan(initialBalance.earned);
|
|
expect(newBalance.tasksCompleted).toBeGreaterThan(initialBalance.tasksCompleted);
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should accumulate credits across multiple tasks', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
const initialBalance = await getQDAGBalance(worker);
|
|
let expectedIncrease = 0n;
|
|
|
|
// Complete multiple tasks
|
|
for (let i = 0; i < 3; i++) {
|
|
const result = await submitAndCompleteTask(submitter, worker, 1000000n);
|
|
if (result) {
|
|
expectedIncrease += result.reward;
|
|
}
|
|
// Small delay between tasks
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
}
|
|
|
|
// Verify total accumulation
|
|
const finalBalance = await getQDAGBalance(worker);
|
|
const actualIncrease = finalBalance.earned - initialBalance.earned;
|
|
|
|
expect(actualIncrease).toBeGreaterThan(0n);
|
|
console.log('[Test 1b] Accumulated:', actualIncrease.toString(), 'from', finalBalance.tasksCompleted - initialBalance.tasksCompleted, 'tasks');
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT * 2);
|
|
});
|
|
|
|
describe('Test 2: Credits sync to QDAG after task completion', () => {
|
|
it('should immediately sync credits to QDAG on task completion', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
const initialBalance = await getQDAGBalance(worker);
|
|
|
|
// Complete task
|
|
const result = await submitAndCompleteTask(submitter, worker, 3000000n);
|
|
expect(result).toBeTruthy();
|
|
|
|
// QDAG should be updated immediately (within the same request)
|
|
// The credit_earned message includes the updated balance
|
|
const creditMsg = worker.messages.find(m => m.type === 'credit_earned');
|
|
expect(creditMsg).toBeTruthy();
|
|
expect(creditMsg?.balance).toBeDefined();
|
|
|
|
const returnedEarned = BigInt(creditMsg?.balance?.earned || '0');
|
|
expect(returnedEarned).toBeGreaterThan(initialBalance.earned);
|
|
|
|
// Verify by requesting fresh balance from QDAG
|
|
const qdagBalance = await getQDAGBalance(worker);
|
|
expect(qdagBalance.earned).toBe(returnedEarned);
|
|
|
|
console.log('[Test 2] QDAG synced immediately:', qdagBalance.earned.toString());
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should update QDAG atomically with task completion', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
// Complete a task
|
|
await submitAndCompleteTask(submitter, worker, 1500000n);
|
|
|
|
// The relay returns updated balance in credit_earned message
|
|
// This ensures atomicity - client gets consistent state
|
|
const creditMsg = worker.messages.find(m => m.type === 'credit_earned');
|
|
expect(creditMsg).toBeTruthy();
|
|
expect(creditMsg?.balance).toBeDefined();
|
|
expect(BigInt(creditMsg?.balance?.available || '0')).toBeGreaterThan(0n);
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
});
|
|
|
|
describe('Test 3: After refresh, QDAG balance is loaded', () => {
|
|
it('should load same balance after reconnection with same publicKey', async () => {
|
|
// Use consistent public key for this test
|
|
const testPublicKey = `persist-test-${randomBytes(16).toString('hex')}`;
|
|
|
|
// First session
|
|
let worker = await createTestNode('worker1', testPublicKey);
|
|
const submitter = await createTestNode();
|
|
|
|
try {
|
|
// Earn some credits
|
|
await submitAndCompleteTask(submitter, worker, 2500000n);
|
|
const sessionOneBalance = await getQDAGBalance(worker);
|
|
console.log('[Test 3] Session 1 balance:', sessionOneBalance.earned.toString());
|
|
|
|
// Disconnect worker (simulate page refresh)
|
|
closeNode(worker);
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// Reconnect with SAME publicKey (simulates browser refresh)
|
|
worker = await createTestNode('worker2', testPublicKey);
|
|
|
|
// Request balance from QDAG
|
|
const sessionTwoBalance = await getQDAGBalance(worker);
|
|
console.log('[Test 3] Session 2 balance:', sessionTwoBalance.earned.toString());
|
|
|
|
// Balance should be preserved
|
|
expect(sessionTwoBalance.earned).toBe(sessionOneBalance.earned);
|
|
expect(sessionTwoBalance.tasksCompleted).toBe(sessionOneBalance.tasksCompleted);
|
|
|
|
} finally {
|
|
closeNode(worker);
|
|
closeNode(submitter);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should maintain balance across multiple reconnections', async () => {
|
|
const testPublicKey = `multi-persist-${randomBytes(16).toString('hex')}`;
|
|
const submitter = await createTestNode();
|
|
let worker: TestNode | null = null;
|
|
let lastKnownBalance = 0n;
|
|
|
|
try {
|
|
// Multiple connect/disconnect cycles
|
|
for (let cycle = 0; cycle < 3; cycle++) {
|
|
worker = await createTestNode(`worker-cycle-${cycle}`, testPublicKey);
|
|
|
|
// Verify balance is at least what we expect
|
|
const balance = await getQDAGBalance(worker);
|
|
expect(balance.earned).toBeGreaterThanOrEqual(lastKnownBalance);
|
|
|
|
// Earn more credits
|
|
const result = await submitAndCompleteTask(submitter, worker, 1000000n);
|
|
if (result) {
|
|
const newBalance = await getQDAGBalance(worker);
|
|
lastKnownBalance = newBalance.earned;
|
|
console.log(`[Test 3b] Cycle ${cycle + 1} balance:`, lastKnownBalance.toString());
|
|
}
|
|
|
|
// Disconnect
|
|
closeNode(worker);
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
}
|
|
|
|
// Final verification
|
|
worker = await createTestNode('worker-final', testPublicKey);
|
|
const finalBalance = await getQDAGBalance(worker);
|
|
expect(finalBalance.earned).toBe(lastKnownBalance);
|
|
|
|
} finally {
|
|
if (worker) closeNode(worker);
|
|
closeNode(submitter);
|
|
}
|
|
}, TEST_TIMEOUT * 2);
|
|
});
|
|
|
|
describe('Test 4: Pending credits shown correctly before sync', () => {
|
|
it('should show 0 pending for new identity', async () => {
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
const balance = await getQDAGBalance(worker);
|
|
|
|
// New identity starts with 0
|
|
// "Pending" is managed locally, QDAG only tracks confirmed credits
|
|
expect(balance.earned).toBe(0n);
|
|
expect(balance.spent).toBe(0n);
|
|
|
|
} finally {
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should receive credit confirmation with task completion', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
// Before task: 0 credits
|
|
const beforeBalance = await getQDAGBalance(worker);
|
|
|
|
// Submit task
|
|
submitter.ws.send(JSON.stringify({
|
|
type: 'task_submit',
|
|
task: {
|
|
type: 'inference',
|
|
model: 'test-model',
|
|
maxCredits: '2000000',
|
|
},
|
|
}));
|
|
|
|
// Wait for task acceptance and assignment
|
|
await waitForMessage(submitter, 'task_accepted');
|
|
await waitForMessage(worker, 'task_assignment');
|
|
|
|
// At this point, credits are "pending" conceptually
|
|
// (the task is assigned but not yet completed)
|
|
|
|
// Complete the task
|
|
const msgIndex = worker.messages.length;
|
|
worker.ws.send(JSON.stringify({
|
|
type: 'task_complete',
|
|
taskId: (await waitForMessage(worker, 'task_assignment'))?.task?.id,
|
|
result: { done: true },
|
|
reward: '2000000',
|
|
}));
|
|
|
|
// Wait for credit confirmation (not pending anymore)
|
|
const creditMsg = await waitForLatestMessage(worker, 'credit_earned');
|
|
expect(creditMsg).toBeTruthy();
|
|
|
|
// After confirmation: credits are confirmed in QDAG
|
|
const afterBalance = await getQDAGBalance(worker);
|
|
expect(afterBalance.earned).toBeGreaterThan(beforeBalance.earned);
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
});
|
|
|
|
describe('Test 5: Double-sync prevention works', () => {
|
|
it('should prevent double completion of same task', async () => {
|
|
const submitter = await createTestNode();
|
|
const worker = await createTestNode();
|
|
|
|
try {
|
|
// Submit task
|
|
submitter.ws.send(JSON.stringify({
|
|
type: 'task_submit',
|
|
task: {
|
|
type: 'inference',
|
|
model: 'test-model',
|
|
maxCredits: '5000000',
|
|
},
|
|
}));
|
|
|
|
await waitForMessage(submitter, 'task_accepted');
|
|
const assignment = await waitForMessage(worker, 'task_assignment');
|
|
const taskId = assignment?.task?.id;
|
|
expect(taskId).toBeTruthy();
|
|
|
|
// First completion
|
|
worker.messages.length = 0;
|
|
worker.ws.send(JSON.stringify({
|
|
type: 'task_complete',
|
|
taskId: taskId,
|
|
result: { done: true },
|
|
reward: '5000000',
|
|
}));
|
|
|
|
const firstCredit = await waitForMessage(worker, 'credit_earned', 3000);
|
|
expect(firstCredit).toBeTruthy();
|
|
const firstEarned = BigInt(firstCredit?.balance?.earned || '0');
|
|
|
|
// Get balance after first completion
|
|
const balanceAfterFirst = await getQDAGBalance(worker);
|
|
|
|
// Wait a moment
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
worker.messages.length = 0;
|
|
|
|
// Second completion (should fail - replay attack)
|
|
worker.ws.send(JSON.stringify({
|
|
type: 'task_complete',
|
|
taskId: taskId,
|
|
result: { done: true },
|
|
reward: '5000000',
|
|
}));
|
|
|
|
// Should get error, not credit
|
|
const secondResponse = await waitForMessage(worker, 'error', 2000);
|
|
expect(secondResponse).toBeTruthy();
|
|
expect(secondResponse?.message).toContain('already completed');
|
|
|
|
// Should NOT receive second credit
|
|
const secondCredit = worker.messages.find(m => m.type === 'credit_earned');
|
|
expect(secondCredit).toBeFalsy();
|
|
|
|
// Verify balance unchanged after double-complete attempt
|
|
const balanceAfterSecond = await getQDAGBalance(worker);
|
|
expect(balanceAfterSecond.earned).toBe(balanceAfterFirst.earned);
|
|
|
|
console.log('[Test 5] Double-completion prevented, balance unchanged:', balanceAfterSecond.earned.toString());
|
|
|
|
} finally {
|
|
closeNode(submitter);
|
|
closeNode(worker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should prevent client from self-reporting credits', async () => {
|
|
const attacker = await createTestNode();
|
|
|
|
try {
|
|
const initialBalance = await getQDAGBalance(attacker);
|
|
attacker.messages.length = 0;
|
|
|
|
// Attempt to self-report credits (should be rejected)
|
|
attacker.ws.send(JSON.stringify({
|
|
type: 'ledger_update',
|
|
publicKey: attacker.publicKey,
|
|
earned: '999999999999', // Try to give self many credits
|
|
spent: '0',
|
|
}));
|
|
|
|
// Should receive error
|
|
const error = await waitForMessage(attacker, 'error', 2000);
|
|
expect(error).toBeTruthy();
|
|
expect(error?.message).toContain('self-report');
|
|
|
|
// Verify balance unchanged
|
|
const finalBalance = await getQDAGBalance(attacker);
|
|
expect(finalBalance.earned).toBe(initialBalance.earned);
|
|
|
|
console.log('[Test 5b] Self-reporting rejected, balance unchanged');
|
|
|
|
} finally {
|
|
closeNode(attacker);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
});
|
|
|
|
describe('Test 6: Rate limiting prevents abuse', () => {
|
|
it('should rate limit ledger sync requests', async () => {
|
|
const node = await createTestNode();
|
|
|
|
try {
|
|
// Clear messages
|
|
node.messages.length = 0;
|
|
|
|
// Send many ledger_sync requests rapidly
|
|
const BURST_SIZE = 50;
|
|
for (let i = 0; i < BURST_SIZE; i++) {
|
|
node.ws.send(JSON.stringify({
|
|
type: 'ledger_sync',
|
|
publicKey: node.publicKey,
|
|
}));
|
|
}
|
|
|
|
// Wait for responses
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Count responses and errors
|
|
const syncResponses = node.messages.filter(m => m.type === 'ledger_sync_response');
|
|
const rateLimitErrors = node.messages.filter(m =>
|
|
m.type === 'error' && m.message?.includes('Rate limit')
|
|
);
|
|
|
|
// Should get some responses and possibly some rate limit errors
|
|
// The exact behavior depends on rate limit configuration
|
|
console.log('[Test 6] Burst results:', {
|
|
responses: syncResponses.length,
|
|
rateLimitErrors: rateLimitErrors.length,
|
|
});
|
|
|
|
// At minimum, should have handled requests (either with response or rate limit)
|
|
expect(syncResponses.length + rateLimitErrors.length).toBeGreaterThan(0);
|
|
|
|
} finally {
|
|
closeNode(node);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
|
|
it('should rate limit task submissions', async () => {
|
|
const node = await createTestNode();
|
|
|
|
try {
|
|
node.messages.length = 0;
|
|
|
|
// Send many task submissions rapidly
|
|
const BURST_SIZE = 30;
|
|
for (let i = 0; i < BURST_SIZE; i++) {
|
|
node.ws.send(JSON.stringify({
|
|
type: 'task_submit',
|
|
task: {
|
|
type: 'inference',
|
|
model: 'test-model',
|
|
maxCredits: '1000',
|
|
},
|
|
}));
|
|
}
|
|
|
|
// Wait for responses
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
const accepted = node.messages.filter(m => m.type === 'task_accepted');
|
|
const errors = node.messages.filter(m => m.type === 'error');
|
|
|
|
console.log('[Test 6b] Task burst results:', {
|
|
accepted: accepted.length,
|
|
errors: errors.length,
|
|
});
|
|
|
|
// Should process requests within rate limits
|
|
expect(accepted.length + errors.length).toBeGreaterThan(0);
|
|
|
|
} finally {
|
|
closeNode(node);
|
|
}
|
|
}, TEST_TIMEOUT);
|
|
});
|
|
|
|
describe('Cross-device Credit Sync (Same PublicKey)', () => {
|
|
it('should share credits across multiple connections with same publicKey', async () => {
|
|
const sharedPublicKey = `shared-${randomBytes(16).toString('hex')}`;
|
|
const submitter = await createTestNode();
|
|
|
|
// Device 1
|
|
const device1 = await createTestNode('device1', sharedPublicKey);
|
|
|
|
try {
|
|
// Earn credits on device1
|
|
await submitAndCompleteTask(submitter, device1, 3000000n);
|
|
const device1Balance = await getQDAGBalance(device1);
|
|
console.log('[Cross-device] Device 1 earned:', device1Balance.earned.toString());
|
|
|
|
// Device 2 connects with same publicKey
|
|
const device2 = await createTestNode('device2', sharedPublicKey);
|
|
|
|
try {
|
|
// Device 2 should see same balance
|
|
const device2Balance = await getQDAGBalance(device2);
|
|
console.log('[Cross-device] Device 2 sees:', device2Balance.earned.toString());
|
|
|
|
expect(device2Balance.earned).toBe(device1Balance.earned);
|
|
expect(device2Balance.tasksCompleted).toBe(device1Balance.tasksCompleted);
|
|
|
|
// Device 2 earns more credits
|
|
await submitAndCompleteTask(submitter, device2, 2000000n);
|
|
const device2NewBalance = await getQDAGBalance(device2);
|
|
console.log('[Cross-device] Device 2 new balance:', device2NewBalance.earned.toString());
|
|
|
|
// Device 1 should see updated balance
|
|
const device1NewBalance = await getQDAGBalance(device1);
|
|
console.log('[Cross-device] Device 1 sees update:', device1NewBalance.earned.toString());
|
|
|
|
expect(device1NewBalance.earned).toBe(device2NewBalance.earned);
|
|
|
|
} finally {
|
|
closeNode(device2);
|
|
}
|
|
|
|
} finally {
|
|
closeNode(device1);
|
|
closeNode(submitter);
|
|
}
|
|
}, TEST_TIMEOUT * 2);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// DOCUMENTATION: Credit Persistence Flow
|
|
// =============================================================================
|
|
/*
|
|
|
|
## Credit Persistence Architecture
|
|
|
|
### 1. Source of Truth: QDAG (Firestore)
|
|
- Credits are stored in Firestore keyed by PUBLIC KEY
|
|
- Same publicKey = same credits everywhere (CLI, dashboard, mobile)
|
|
- Only the relay server can credit accounts (via verified task completions)
|
|
|
|
### 2. Credit Flow
|
|
|
|
```
|
|
[User completes task]
|
|
|
|
|
v
|
|
[Relay verifies task was assigned to this node]
|
|
|
|
|
v
|
|
[Relay credits account in QDAG (Firestore)]
|
|
|
|
|
v
|
|
[Relay sends credit_earned to node with new balance]
|
|
|
|
|
v
|
|
[Dashboard updates local state from credit_earned]
|
|
|
|
|
v
|
|
[Dashboard saves to IndexedDB as backup]
|
|
```
|
|
|
|
### 3. Reconnection Flow
|
|
|
|
```
|
|
[Browser refreshed / app reopened]
|
|
|
|
|
v
|
|
[Load from IndexedDB (local backup)]
|
|
|
|
|
v
|
|
[Connect to relay with publicKey]
|
|
|
|
|
v
|
|
[Request ledger_sync from QDAG]
|
|
|
|
|
v
|
|
[QDAG returns authoritative balance]
|
|
|
|
|
v
|
|
[Update local state to match QDAG]
|
|
```
|
|
|
|
### 4. Security Measures
|
|
|
|
1. **No self-reporting**: Clients cannot submit their own credit values
|
|
- `ledger_update` messages are rejected with error
|
|
|
|
2. **Task verification**: Only assigned tasks can be completed
|
|
- `assignedTasks` map tracks who was assigned each task
|
|
- Completion attempts from wrong node are rejected
|
|
|
|
3. **Replay prevention**: Tasks can only be completed once
|
|
- `completedTasks` set prevents double-completion
|
|
|
|
4. **Rate limiting**: Prevents request flooding
|
|
- RATE_LIMIT_MAX messages per RATE_LIMIT_WINDOW
|
|
|
|
5. **Public key identity**: Credits tied to cryptographic identity
|
|
- Same key = same balance everywhere
|
|
- Different key = separate account
|
|
|
|
### 5. IndexedDB Schema (Local Backup)
|
|
|
|
```typescript
|
|
interface NodeState {
|
|
id: string; // 'primary'
|
|
nodeId: string; // WASM node ID
|
|
creditsEarned: number;
|
|
creditsSpent: number;
|
|
tasksCompleted: number;
|
|
totalUptime: number;
|
|
consentGiven: boolean;
|
|
// ... contribution settings
|
|
}
|
|
```
|
|
|
|
### 6. QDAG Ledger Schema (Firestore - Source of Truth)
|
|
|
|
```typescript
|
|
interface QDAGLedger {
|
|
earned: number; // Total rUv earned (float, converted from nanoRuv)
|
|
spent: number; // Total rUv spent
|
|
tasksCompleted: number;
|
|
createdAt: number; // Timestamp
|
|
updatedAt: number; // Last update timestamp
|
|
lastTaskId: string; // Last completed task ID
|
|
}
|
|
```
|
|
|
|
### 7. Key Files
|
|
|
|
- `/relay/index.js` - Relay server with QDAG integration
|
|
- `/dashboard/src/services/relayClient.ts` - WebSocket client
|
|
- `/dashboard/src/services/storage.ts` - IndexedDB service
|
|
- `/dashboard/src/stores/networkStore.ts` - Zustand store with credit state
|
|
|
|
*/
|