/** * Spatial Payment Verification Engine * * Core protocol: * 1. Robot claims to be at pose P * 2. Render expected view from long-term memory at P * 3. Capture actual camera view * 4. Compare: SSIM + LPIPS + depth MAE → composite score * 5. Score > threshold → spatial proof generated → payment proceeds * * Anti-tamper: * - Proof includes Merkle root linking to long-term memory * - HMAC-SHA256 signed proof payload * - Cryptographic nonce prevents replay * - Hardware attestation binds proof to physical device */ import type { CameraFrame, RenderedView, SpatialMetrics, SpatialProof, SpatialSettlement, SettlementStatus, Pose, VerificationThresholds, } from '../types/index.js'; import { hmacSign, hmacVerify, generateNonce, deriveKey } from '../utils/crypto.js'; import { meanAbsoluteError } from '../utils/math.js'; // ============================================================ // SSIM — Structural Similarity Index // ============================================================ /** * Compute SSIM between two grayscale images * Based on Wang et al. (2004) — measures structural similarity * Range: [-1, 1], where 1 = identical * * SSIM(x,y) = (2*μx*μy + C1)(2*σxy + C2) / ((μx² + μy² + C1)(σx² + σy² + C2)) */ export function computeSSIM( imgA: Uint8Array, imgB: Uint8Array, width: number, height: number, windowSize: number = 8, ): number { if (imgA.length !== imgB.length) { throw new Error('Image dimensions must match for SSIM'); } if (imgA.length === 0) return 0; // Constants (as per original paper) const L = 255; // dynamic range const k1 = 0.01, k2 = 0.03; const C1 = (k1 * L) ** 2; const C2 = (k2 * L) ** 2; let totalSSIM = 0; let windowCount = 0; // Slide window across image for (let y = 0; y <= height - windowSize; y += windowSize) { for (let x = 0; x <= width - windowSize; x += windowSize) { let sumA = 0, sumB = 0; let sumA2 = 0, sumB2 = 0; let sumAB = 0; const n = windowSize * windowSize; for (let wy = 0; wy < windowSize; wy++) { for (let wx = 0; wx < windowSize; wx++) { const idx = (y + wy) * width + (x + wx); const a = imgA[idx]!; const b = imgB[idx]!; sumA += a; sumB += b; sumA2 += a * a; sumB2 += b * b; sumAB += a * b; } } const muA = sumA / n; const muB = sumB / n; const sigA2 = sumA2 / n - muA * muA; const sigB2 = sumB2 / n - muB * muB; const sigAB = sumAB / n - muA * muB; const numerator = (2 * muA * muB + C1) * (2 * sigAB + C2); const denominator = (muA * muA + muB * muB + C1) * (sigA2 + sigB2 + C2); totalSSIM += numerator / denominator; windowCount++; } } return windowCount > 0 ? totalSSIM / windowCount : 0; } /** * Convert RGB image to grayscale for SSIM computation * Uses ITU-R BT.601 luma coefficients: 0.299R + 0.587G + 0.114B */ export function rgbToGrayscale(rgb: Uint8Array, width: number, height: number): Uint8Array { const gray = new Uint8Array(width * height); for (let i = 0; i < width * height; i++) { const r = rgb[i * 3]!; const g = rgb[i * 3 + 1]!; const b = rgb[i * 3 + 2]!; gray[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b); } return gray; } // ============================================================ // LPIPS Approximation (Perceptual Similarity) // ============================================================ /** * Approximate LPIPS using multi-scale edge + texture comparison * * True LPIPS requires a neural network (VGG/AlexNet). For on-device * robotics we use a lightweight approximation: * 1. Sobel edge detection at multiple scales * 2. Local variance (texture) comparison * 3. Histogram distance * * Range: [0, 1], where 0 = identical (opposite of SSIM direction) */ export function approximateLPIPS( imgA: Uint8Array, imgB: Uint8Array, width: number, height: number, ): number { if (imgA.length !== imgB.length) { throw new Error('Image dimensions must match for LPIPS'); } if (imgA.length === 0) return 1; // 1. Edge comparison (Sobel) const edgesA = sobelEdges(imgA, width, height); const edgesB = sobelEdges(imgB, width, height); let edgeDiff = 0; for (let i = 0; i < edgesA.length; i++) { edgeDiff += Math.abs(edgesA[i]! - edgesB[i]!) / 255; } const edgeScore = edgeDiff / edgesA.length; // 2. Local variance (texture) comparison const varA = localVariance(imgA, width, height, 4); const varB = localVariance(imgB, width, height, 4); let varDiff = 0; for (let i = 0; i < varA.length; i++) { varDiff += Math.abs(varA[i]! - varB[i]!); } const maxVar = Math.max(1, Math.max(...varA, ...varB)); const textureScore = varDiff / (varA.length * maxVar); // 3. Histogram distance (L1) const histA = histogram(imgA); const histB = histogram(imgB); let histDiff = 0; for (let i = 0; i < 256; i++) { histDiff += Math.abs(histA[i]! - histB[i]!); } const histScore = histDiff / (2 * imgA.length); // normalize to [0,1] // Weighted combination return Math.min(1, 0.4 * edgeScore + 0.4 * textureScore + 0.2 * histScore); } /** Sobel edge detection (magnitude) */ function sobelEdges(img: Uint8Array, width: number, height: number): Float32Array { const edges = new Float32Array(width * height); for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = y * width + x; // Horizontal Sobel const gx = -img[(y - 1) * width + (x - 1)]! - 2 * img[y * width + (x - 1)]! - img[(y + 1) * width + (x - 1)]! + img[(y - 1) * width + (x + 1)]! + 2 * img[y * width + (x + 1)]! + img[(y + 1) * width + (x + 1)]!; // Vertical Sobel const gy = -img[(y - 1) * width + (x - 1)]! - 2 * img[(y - 1) * width + x]! - img[(y - 1) * width + (x + 1)]! + img[(y + 1) * width + (x - 1)]! + 2 * img[(y + 1) * width + x]! + img[(y + 1) * width + (x + 1)]!; edges[idx] = Math.sqrt(gx * gx + gy * gy); } } return edges; } /** Local variance in blocks */ function localVariance(img: Uint8Array, width: number, height: number, blockSize: number): Float32Array { const bw = Math.floor(width / blockSize); const bh = Math.floor(height / blockSize); const result = new Float32Array(bw * bh); for (let by = 0; by < bh; by++) { for (let bx = 0; bx < bw; bx++) { let sum = 0, sum2 = 0; const n = blockSize * blockSize; for (let dy = 0; dy < blockSize; dy++) { for (let dx = 0; dx < blockSize; dx++) { const val = img[(by * blockSize + dy) * width + (bx * blockSize + dx)]!; sum += val; sum2 += val * val; } } const mean = sum / n; result[by * bw + bx] = sum2 / n - mean * mean; } } return result; } /** 256-bin histogram */ function histogram(img: Uint8Array): Float32Array { const hist = new Float32Array(256); for (let i = 0; i < img.length; i++) { const val = img[i]!; hist[val] = (hist[val] ?? 0) + 1; } return hist; } // ============================================================ // SPATIAL METRICS COMPUTATION // ============================================================ /** * Compute spatial verification metrics * Compares expected render vs actual camera capture * * @param confidence - Optional place cell confidence [0,1]. When provided and * thresholds.adaptiveScaling !== false, thresholds are scaled by * max(0.85, confidence) so lower-confidence areas get slightly relaxed * thresholds while maintaining a safety floor. * @param opsPerSec - Optional throughput rate. When > 1000, composite weights * shift from LPIPS (expensive, less reliable under load) toward SSIM + depth. */ export function computeSpatialMetrics( expected: RenderedView, actual: CameraFrame, thresholds: VerificationThresholds, confidence?: number, opsPerSec?: number, ): SpatialMetrics { // Validate dimensions match if (expected.width !== actual.width || expected.height !== actual.height) { throw new Error( `Dimension mismatch: expected ${expected.width}x${expected.height}, ` + `actual ${actual.width}x${actual.height}`, ); } // Convert to grayscale for SSIM const grayExpected = rgbToGrayscale(expected.rgb, expected.width, expected.height); const grayActual = rgbToGrayscale(actual.rgb, actual.width, actual.height); // SSIM: structural similarity [0,1] const ssim = computeSSIM(grayExpected, grayActual, expected.width, expected.height); // LPIPS approximation: perceptual dissimilarity [0,1] const lpips = approximateLPIPS(grayExpected, grayActual, expected.width, expected.height); // Depth MAE (meters) — only if both have depth let depthMAE = 0; if (expected.depth && actual.depth) { depthMAE = meanAbsoluteError(expected.depth, actual.depth); } // Adaptive threshold adjustment based on place cell confidence // Floor at 0.85 so we never go more than 15% loose let effectiveMaxDepthMAE = thresholds.maxDepthMAE; if (confidence !== undefined && thresholds.adaptiveScaling !== false) { const confidenceFactor = Math.max(0.85, confidence); // Scale maxDepthMAE inversely (higher confidence = tighter depth tolerance) // When confidence is low, allow slightly more depth error effectiveMaxDepthMAE = thresholds.maxDepthMAE / confidenceFactor; } // Composite score: weighted combination // SSIM contributes positively, LPIPS and depth MAE contribute negatively const ssimNorm = Math.max(0, ssim); // [0,1] const lpipsNorm = 1 - Math.min(1, lpips); // invert: [0,1] where 1=good const depthNorm = 1 - Math.min(1, depthMAE / effectiveMaxDepthMAE); // [0,1] // Rate-dependent composite weights (Fix 4) // Under high load (>1000 ops/sec), LPIPS is expensive and less reliable; // shift weight toward SSIM + depth which are cheaper and more stable. let wSSIM = 0.4, wLPIPS = 0.3, wDepth = 0.3; if (opsPerSec !== undefined && opsPerSec > 1000) { wSSIM = 0.5; wLPIPS = 0.2; wDepth = 0.3; } const composite = wSSIM * ssimNorm + wLPIPS * lpipsNorm + wDepth * depthNorm; return { ssim, lpips, depthMAE, composite }; } // ============================================================ // SPATIAL PROOF GENERATION // ============================================================ const DEFAULT_THRESHOLDS: VerificationThresholds = { minSSIM: 0.75, maxLPIPS: 0.25, maxDepthMAE: 0.5, // meters minComposite: 0.75, adaptiveScaling: true, }; /** * Generate a spatial proof — cryptographic proof that robot is at claimed location * * @param prevProofHash - SHA-256 hash of the previous proof in the chain (undefined for genesis) * @param confidence - Optional place cell confidence [0,1] for adaptive thresholds * @param opsPerSec - Optional throughput rate for rate-dependent composite weights */ export function generateSpatialProof( robotId: string, claimedPose: Pose, actualFrame: CameraFrame, expectedRender: RenderedView, memoryMerkleRoot: string, merkleProof: readonly string[], hmacSecret: string, thresholds: VerificationThresholds = DEFAULT_THRESHOLDS, hardwareAttestation?: string, prevProofHash?: string, confidence?: number, opsPerSec?: number, ): SpatialProof { // Validate inputs if (!robotId) throw new Error('robotId is required'); if (!hmacSecret || hmacSecret.length < 32) throw new Error('HMAC secret must be at least 32 chars'); if (!actualFrame.hmac) throw new Error('Actual frame must be HMAC-signed'); // Compute spatial metrics with optional adaptive parameters const metrics = computeSpatialMetrics(expectedRender, actualFrame, thresholds, confidence, opsPerSec); // Adaptive thresholds for pass/fail determination let effectiveMinSSIM = thresholds.minSSIM; let effectiveMinComposite = thresholds.minComposite; if (confidence !== undefined && thresholds.adaptiveScaling !== false) { const confidenceFactor = Math.max(0.85, confidence); effectiveMinSSIM = thresholds.minSSIM * confidenceFactor; effectiveMinComposite = thresholds.minComposite * confidenceFactor; } // Determine if proof passes const passed = metrics.ssim >= effectiveMinSSIM && metrics.lpips <= thresholds.maxLPIPS && metrics.depthMAE <= thresholds.maxDepthMAE && metrics.composite >= effectiveMinComposite; // Generate nonce for replay prevention const nonce = generateNonce(32); // Build proof payload const proofId = generateNonce(16); const timestamp = Date.now(); // Sign the proof — includes prevProofHash so chain position is cryptographically bound const proofKey = deriveKey(hmacSecret, 'spatial-proof'); const signatureData = Buffer.from( [ proofId, robotId, timestamp.toString(), nonce, passed.toString(), metrics.composite.toFixed(6), memoryMerkleRoot, actualFrame.id, prevProofHash ?? 'genesis', ].join(':'), ); const signature = hmacSign(signatureData, proofKey); return { id: proofId, robotId, claimedPose, actualFrame, expectedRender, metrics, passed, memoryMerkleRoot, merkleProof, timestamp, signature, nonce, hardwareAttestation, prevProofHash, }; } /** * Verify a spatial proof's integrity (does NOT re-run image comparison) * Checks: HMAC signature, nonce freshness, Merkle root validity */ export function verifySpatialProofIntegrity( proof: SpatialProof, hmacSecret: string, maxAgeMs: number = 300_000, // 5 minute max proof age ): { valid: boolean; reason?: string } { // Check proof age const age = Date.now() - proof.timestamp; if (age > maxAgeMs) { return { valid: false, reason: `Proof expired: age ${age}ms > max ${maxAgeMs}ms` }; } if (age < 0) { return { valid: false, reason: 'Proof timestamp is in the future' }; } // Verify HMAC signature — must include prevProofHash to prevent chain reordering const proofKey = deriveKey(hmacSecret, 'spatial-proof'); const signatureData = Buffer.from( [ proof.id, proof.robotId, proof.timestamp.toString(), proof.nonce, proof.passed.toString(), proof.metrics.composite.toFixed(6), proof.memoryMerkleRoot, proof.actualFrame.id, proof.prevProofHash ?? 'genesis', ].join(':'), ); if (!hmacVerify(signatureData, proof.signature, proofKey)) { return { valid: false, reason: 'HMAC signature mismatch — proof may be tampered' }; } return { valid: true }; } // ============================================================ // SETTLEMENT // ============================================================ /** * Create a spatial settlement (payment tied to spatial proof) * Atomic: either the proof is valid AND payment succeeds, or both fail */ export function createSettlement( proof: SpatialProof, amount: number, currency: string, payeeId: string, hmacSecret: string, ): SpatialSettlement { if (amount <= 0) throw new Error('Settlement amount must be positive'); if (!currency) throw new Error('Currency is required'); if (!payeeId) throw new Error('Payee ID is required'); // Verify proof integrity first const integrity = verifySpatialProofIntegrity(proof, hmacSecret); let status: SettlementStatus; let failureReason: string | undefined; if (!integrity.valid) { status = 'failed' as SettlementStatus; failureReason = `Proof integrity check failed: ${integrity.reason}`; } else if (!proof.passed) { status = 'failed' as SettlementStatus; failureReason = `Spatial verification failed: composite score ${proof.metrics.composite.toFixed(3)} < threshold`; } else { status = 'verified' as SettlementStatus; } return { id: generateNonce(16), proof, amount, currency, status, payerRobotId: proof.robotId, payeeId, initiatedAt: Date.now(), settledAt: status === 'verified' ? Date.now() : undefined, failureReason, }; }