gridstamp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.cursorrules +74 -0
  2. package/CLAUDE.md +61 -0
  3. package/LICENSE +190 -0
  4. package/README.md +107 -0
  5. package/dist/index.js +194 -0
  6. package/package.json +84 -0
  7. package/src/antispoofing/detector.ts +509 -0
  8. package/src/antispoofing/index.ts +7 -0
  9. package/src/gamification/badges.ts +429 -0
  10. package/src/gamification/fleet-leaderboard.ts +293 -0
  11. package/src/gamification/index.ts +44 -0
  12. package/src/gamification/streaks.ts +243 -0
  13. package/src/gamification/trust-tiers.ts +393 -0
  14. package/src/gamification/zone-mastery.ts +256 -0
  15. package/src/index.ts +341 -0
  16. package/src/memory/index.ts +9 -0
  17. package/src/memory/place-cells.ts +279 -0
  18. package/src/memory/spatial-memory.ts +375 -0
  19. package/src/navigation/index.ts +1 -0
  20. package/src/navigation/pathfinding.ts +403 -0
  21. package/src/perception/camera.ts +249 -0
  22. package/src/perception/index.ts +2 -0
  23. package/src/types/index.ts +416 -0
  24. package/src/utils/crypto.ts +94 -0
  25. package/src/utils/index.ts +2 -0
  26. package/src/utils/math.ts +204 -0
  27. package/src/verification/index.ts +9 -0
  28. package/src/verification/spatial-proof.ts +442 -0
  29. package/tests/antispoofing/detector.test.ts +196 -0
  30. package/tests/gamification/badges.test.ts +163 -0
  31. package/tests/gamification/fleet-leaderboard.test.ts +181 -0
  32. package/tests/gamification/streaks.test.ts +158 -0
  33. package/tests/gamification/trust-tiers.test.ts +165 -0
  34. package/tests/gamification/zone-mastery.test.ts +143 -0
  35. package/tests/memory/place-cells.test.ts +128 -0
  36. package/tests/stress/load.test.ts +499 -0
  37. package/tests/stress/security.test.ts +378 -0
  38. package/tests/stress/simulation.test.ts +361 -0
  39. package/tests/utils/crypto.test.ts +115 -0
  40. package/tests/utils/math.test.ts +195 -0
  41. package/tests/verification/spatial-proof.test.ts +299 -0
  42. package/tsconfig.json +26 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Math utilities for GridStamp
3
+ * Vector ops, quaternion math, coordinate transforms
4
+ */
5
+ import type { Vec3, Quaternion, Mat4, Pose } from '../types/index.js';
6
+
7
+ // ============================================================
8
+ // VECTOR OPERATIONS
9
+ // ============================================================
10
+
11
+ export function vec3Add(a: Vec3, b: Vec3): Vec3 {
12
+ return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
13
+ }
14
+
15
+ export function vec3Sub(a: Vec3, b: Vec3): Vec3 {
16
+ return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
17
+ }
18
+
19
+ export function vec3Scale(v: Vec3, s: number): Vec3 {
20
+ return { x: v.x * s, y: v.y * s, z: v.z * s };
21
+ }
22
+
23
+ export function vec3Dot(a: Vec3, b: Vec3): number {
24
+ return a.x * b.x + a.y * b.y + a.z * b.z;
25
+ }
26
+
27
+ export function vec3Length(v: Vec3): number {
28
+ return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
29
+ }
30
+
31
+ export function vec3Normalize(v: Vec3): Vec3 {
32
+ const len = vec3Length(v);
33
+ if (len < 1e-10) return { x: 0, y: 0, z: 0 };
34
+ return vec3Scale(v, 1 / len);
35
+ }
36
+
37
+ export function vec3Distance(a: Vec3, b: Vec3): number {
38
+ return vec3Length(vec3Sub(a, b));
39
+ }
40
+
41
+ export function vec3Cross(a: Vec3, b: Vec3): Vec3 {
42
+ return {
43
+ x: a.y * b.z - a.z * b.y,
44
+ y: a.z * b.x - a.x * b.z,
45
+ z: a.x * b.y - a.y * b.x,
46
+ };
47
+ }
48
+
49
+ export function vec3Lerp(a: Vec3, b: Vec3, t: number): Vec3 {
50
+ const clamped = Math.max(0, Math.min(1, t));
51
+ return {
52
+ x: a.x + (b.x - a.x) * clamped,
53
+ y: a.y + (b.y - a.y) * clamped,
54
+ z: a.z + (b.z - a.z) * clamped,
55
+ };
56
+ }
57
+
58
+ // ============================================================
59
+ // QUATERNION OPERATIONS
60
+ // ============================================================
61
+
62
+ export const QUAT_IDENTITY: Quaternion = { w: 1, x: 0, y: 0, z: 0 };
63
+
64
+ export function quatMultiply(a: Quaternion, b: Quaternion): Quaternion {
65
+ return {
66
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
67
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
68
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
69
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
70
+ };
71
+ }
72
+
73
+ export function quatConjugate(q: Quaternion): Quaternion {
74
+ return { w: q.w, x: -q.x, y: -q.y, z: -q.z };
75
+ }
76
+
77
+ export function quatNormalize(q: Quaternion): Quaternion {
78
+ const len = Math.sqrt(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z);
79
+ if (len < 1e-10) return QUAT_IDENTITY;
80
+ return { w: q.w / len, x: q.x / len, y: q.y / len, z: q.z / len };
81
+ }
82
+
83
+ /** Rotate vector by quaternion: q * v * q^-1 */
84
+ export function quatRotateVec3(q: Quaternion, v: Vec3): Vec3 {
85
+ const qv: Quaternion = { w: 0, x: v.x, y: v.y, z: v.z };
86
+ const result = quatMultiply(quatMultiply(q, qv), quatConjugate(q));
87
+ return { x: result.x, y: result.y, z: result.z };
88
+ }
89
+
90
+ /** Create quaternion from axis-angle */
91
+ export function quatFromAxisAngle(axis: Vec3, angle: number): Quaternion {
92
+ const halfAngle = angle / 2;
93
+ const s = Math.sin(halfAngle);
94
+ const normalized = vec3Normalize(axis);
95
+ return quatNormalize({
96
+ w: Math.cos(halfAngle),
97
+ x: normalized.x * s,
98
+ y: normalized.y * s,
99
+ z: normalized.z * s,
100
+ });
101
+ }
102
+
103
+ /** Spherical linear interpolation */
104
+ export function quatSlerp(a: Quaternion, b: Quaternion, t: number): Quaternion {
105
+ let dot = a.w * b.w + a.x * b.x + a.y * b.y + a.z * b.z;
106
+ let bAdj = b;
107
+ if (dot < 0) {
108
+ dot = -dot;
109
+ bAdj = { w: -b.w, x: -b.x, y: -b.y, z: -b.z };
110
+ }
111
+ if (dot > 0.9995) {
112
+ // Linear interpolation for very close quaternions
113
+ return quatNormalize({
114
+ w: a.w + (bAdj.w - a.w) * t,
115
+ x: a.x + (bAdj.x - a.x) * t,
116
+ y: a.y + (bAdj.y - a.y) * t,
117
+ z: a.z + (bAdj.z - a.z) * t,
118
+ });
119
+ }
120
+ const theta = Math.acos(dot);
121
+ const sinTheta = Math.sin(theta);
122
+ const wa = Math.sin((1 - t) * theta) / sinTheta;
123
+ const wb = Math.sin(t * theta) / sinTheta;
124
+ return {
125
+ w: wa * a.w + wb * bAdj.w,
126
+ x: wa * a.x + wb * bAdj.x,
127
+ y: wa * a.y + wb * bAdj.y,
128
+ z: wa * a.z + wb * bAdj.z,
129
+ };
130
+ }
131
+
132
+ // ============================================================
133
+ // COORDINATE TRANSFORMS
134
+ // ============================================================
135
+
136
+ /** Pose to 4x4 transformation matrix */
137
+ export function poseToMat4(pose: Pose): Mat4 {
138
+ const { w, x, y, z } = pose.orientation;
139
+ const { x: tx, y: ty, z: tz } = pose.position;
140
+ // Rotation matrix from quaternion
141
+ const xx = x * x, yy = y * y, zz = z * z;
142
+ const xy = x * y, xz = x * z, yz = y * z;
143
+ const wx = w * x, wy = w * y, wz = w * z;
144
+ return [
145
+ 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy), tx,
146
+ 2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx), ty,
147
+ 2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy), tz,
148
+ 0, 0, 0, 1,
149
+ ] as const satisfies Mat4;
150
+ }
151
+
152
+ /** Transform point from egocentric (robot) to allocentric (world) frame */
153
+ export function egoToAllo(point: Vec3, robotPose: Pose): Vec3 {
154
+ const rotated = quatRotateVec3(robotPose.orientation, point);
155
+ return vec3Add(rotated, robotPose.position);
156
+ }
157
+
158
+ /** Transform point from allocentric (world) to egocentric (robot) frame */
159
+ export function alloToEgo(point: Vec3, robotPose: Pose): Vec3 {
160
+ const relative = vec3Sub(point, robotPose.position);
161
+ return quatRotateVec3(quatConjugate(robotPose.orientation), relative);
162
+ }
163
+
164
+ /** Stereo depth from disparity: Z = f * B / d */
165
+ export function stereoDepth(
166
+ focalLength: number,
167
+ baseline: number,
168
+ disparity: number,
169
+ ): number {
170
+ if (disparity <= 0) return Infinity;
171
+ return (focalLength * baseline) / disparity;
172
+ }
173
+
174
+ // ============================================================
175
+ // STATISTICAL
176
+ // ============================================================
177
+
178
+ /** Gaussian function: f(x) = peak * exp(-((x-center)^2) / (2*sigma^2)) */
179
+ export function gaussian(x: number, center: number, sigma: number, peak: number = 1): number {
180
+ const diff = x - center;
181
+ return peak * Math.exp(-(diff * diff) / (2 * sigma * sigma));
182
+ }
183
+
184
+ /** 3D Gaussian: radial falloff from center point */
185
+ export function gaussian3D(point: Vec3, center: Vec3, sigma: number, peak: number = 1): number {
186
+ const dist = vec3Distance(point, center);
187
+ return peak * Math.exp(-(dist * dist) / (2 * sigma * sigma));
188
+ }
189
+
190
+ /** Clamp value to [min, max] */
191
+ export function clamp(value: number, min: number, max: number): number {
192
+ return Math.max(min, Math.min(max, value));
193
+ }
194
+
195
+ /** Mean Absolute Error between two arrays */
196
+ export function meanAbsoluteError(a: Float32Array, b: Float32Array): number {
197
+ if (a.length !== b.length) throw new Error('Array length mismatch');
198
+ if (a.length === 0) return 0;
199
+ let sum = 0;
200
+ for (let i = 0; i < a.length; i++) {
201
+ sum += Math.abs(a[i]! - b[i]!);
202
+ }
203
+ return sum / a.length;
204
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ computeSSIM,
3
+ rgbToGrayscale,
4
+ approximateLPIPS,
5
+ computeSpatialMetrics,
6
+ generateSpatialProof,
7
+ verifySpatialProofIntegrity,
8
+ createSettlement,
9
+ } from './spatial-proof.js';
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Spatial Payment Verification Engine
3
+ *
4
+ * Core protocol:
5
+ * 1. Robot claims to be at pose P
6
+ * 2. Render expected view from long-term memory at P
7
+ * 3. Capture actual camera view
8
+ * 4. Compare: SSIM + LPIPS + depth MAE → composite score
9
+ * 5. Score > threshold → spatial proof generated → payment proceeds
10
+ *
11
+ * Anti-tamper:
12
+ * - Proof includes Merkle root linking to long-term memory
13
+ * - HMAC-SHA256 signed proof payload
14
+ * - Cryptographic nonce prevents replay
15
+ * - Hardware attestation binds proof to physical device
16
+ */
17
+ import type {
18
+ CameraFrame,
19
+ RenderedView,
20
+ SpatialMetrics,
21
+ SpatialProof,
22
+ SpatialSettlement,
23
+ SettlementStatus,
24
+ Pose,
25
+ VerificationThresholds,
26
+ } from '../types/index.js';
27
+ import { hmacSign, generateNonce, deriveKey } from '../utils/crypto.js';
28
+ import { meanAbsoluteError } from '../utils/math.js';
29
+
30
+ // ============================================================
31
+ // SSIM — Structural Similarity Index
32
+ // ============================================================
33
+
34
+ /**
35
+ * Compute SSIM between two grayscale images
36
+ * Based on Wang et al. (2004) — measures structural similarity
37
+ * Range: [-1, 1], where 1 = identical
38
+ *
39
+ * SSIM(x,y) = (2*μx*μy + C1)(2*σxy + C2) / ((μx² + μy² + C1)(σx² + σy² + C2))
40
+ */
41
+ export function computeSSIM(
42
+ imgA: Uint8Array,
43
+ imgB: Uint8Array,
44
+ width: number,
45
+ height: number,
46
+ windowSize: number = 8,
47
+ ): number {
48
+ if (imgA.length !== imgB.length) {
49
+ throw new Error('Image dimensions must match for SSIM');
50
+ }
51
+ if (imgA.length === 0) return 0;
52
+
53
+ // Constants (as per original paper)
54
+ const L = 255; // dynamic range
55
+ const k1 = 0.01, k2 = 0.03;
56
+ const C1 = (k1 * L) ** 2;
57
+ const C2 = (k2 * L) ** 2;
58
+
59
+ let totalSSIM = 0;
60
+ let windowCount = 0;
61
+
62
+ // Slide window across image
63
+ for (let y = 0; y <= height - windowSize; y += windowSize) {
64
+ for (let x = 0; x <= width - windowSize; x += windowSize) {
65
+ let sumA = 0, sumB = 0;
66
+ let sumA2 = 0, sumB2 = 0;
67
+ let sumAB = 0;
68
+ const n = windowSize * windowSize;
69
+
70
+ for (let wy = 0; wy < windowSize; wy++) {
71
+ for (let wx = 0; wx < windowSize; wx++) {
72
+ const idx = (y + wy) * width + (x + wx);
73
+ const a = imgA[idx]!;
74
+ const b = imgB[idx]!;
75
+ sumA += a;
76
+ sumB += b;
77
+ sumA2 += a * a;
78
+ sumB2 += b * b;
79
+ sumAB += a * b;
80
+ }
81
+ }
82
+
83
+ const muA = sumA / n;
84
+ const muB = sumB / n;
85
+ const sigA2 = sumA2 / n - muA * muA;
86
+ const sigB2 = sumB2 / n - muB * muB;
87
+ const sigAB = sumAB / n - muA * muB;
88
+
89
+ const numerator = (2 * muA * muB + C1) * (2 * sigAB + C2);
90
+ const denominator = (muA * muA + muB * muB + C1) * (sigA2 + sigB2 + C2);
91
+
92
+ totalSSIM += numerator / denominator;
93
+ windowCount++;
94
+ }
95
+ }
96
+
97
+ return windowCount > 0 ? totalSSIM / windowCount : 0;
98
+ }
99
+
100
+ /**
101
+ * Convert RGB image to grayscale for SSIM computation
102
+ * Uses ITU-R BT.601 luma coefficients: 0.299R + 0.587G + 0.114B
103
+ */
104
+ export function rgbToGrayscale(rgb: Uint8Array, width: number, height: number): Uint8Array {
105
+ const gray = new Uint8Array(width * height);
106
+ for (let i = 0; i < width * height; i++) {
107
+ const r = rgb[i * 3]!;
108
+ const g = rgb[i * 3 + 1]!;
109
+ const b = rgb[i * 3 + 2]!;
110
+ gray[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
111
+ }
112
+ return gray;
113
+ }
114
+
115
+ // ============================================================
116
+ // LPIPS Approximation (Perceptual Similarity)
117
+ // ============================================================
118
+
119
+ /**
120
+ * Approximate LPIPS using multi-scale edge + texture comparison
121
+ *
122
+ * True LPIPS requires a neural network (VGG/AlexNet). For on-device
123
+ * robotics we use a lightweight approximation:
124
+ * 1. Sobel edge detection at multiple scales
125
+ * 2. Local variance (texture) comparison
126
+ * 3. Histogram distance
127
+ *
128
+ * Range: [0, 1], where 0 = identical (opposite of SSIM direction)
129
+ */
130
+ export function approximateLPIPS(
131
+ imgA: Uint8Array,
132
+ imgB: Uint8Array,
133
+ width: number,
134
+ height: number,
135
+ ): number {
136
+ if (imgA.length !== imgB.length) {
137
+ throw new Error('Image dimensions must match for LPIPS');
138
+ }
139
+ if (imgA.length === 0) return 1;
140
+
141
+ // 1. Edge comparison (Sobel)
142
+ const edgesA = sobelEdges(imgA, width, height);
143
+ const edgesB = sobelEdges(imgB, width, height);
144
+ let edgeDiff = 0;
145
+ for (let i = 0; i < edgesA.length; i++) {
146
+ edgeDiff += Math.abs(edgesA[i]! - edgesB[i]!) / 255;
147
+ }
148
+ const edgeScore = edgeDiff / edgesA.length;
149
+
150
+ // 2. Local variance (texture) comparison
151
+ const varA = localVariance(imgA, width, height, 4);
152
+ const varB = localVariance(imgB, width, height, 4);
153
+ let varDiff = 0;
154
+ for (let i = 0; i < varA.length; i++) {
155
+ varDiff += Math.abs(varA[i]! - varB[i]!);
156
+ }
157
+ const maxVar = Math.max(1, Math.max(...varA, ...varB));
158
+ const textureScore = varDiff / (varA.length * maxVar);
159
+
160
+ // 3. Histogram distance (L1)
161
+ const histA = histogram(imgA);
162
+ const histB = histogram(imgB);
163
+ let histDiff = 0;
164
+ for (let i = 0; i < 256; i++) {
165
+ histDiff += Math.abs(histA[i]! - histB[i]!);
166
+ }
167
+ const histScore = histDiff / (2 * imgA.length); // normalize to [0,1]
168
+
169
+ // Weighted combination
170
+ return Math.min(1, 0.4 * edgeScore + 0.4 * textureScore + 0.2 * histScore);
171
+ }
172
+
173
+ /** Sobel edge detection (magnitude) */
174
+ function sobelEdges(img: Uint8Array, width: number, height: number): Float32Array {
175
+ const edges = new Float32Array(width * height);
176
+ for (let y = 1; y < height - 1; y++) {
177
+ for (let x = 1; x < width - 1; x++) {
178
+ const idx = y * width + x;
179
+ // Horizontal Sobel
180
+ const gx =
181
+ -img[(y - 1) * width + (x - 1)]! - 2 * img[y * width + (x - 1)]! - img[(y + 1) * width + (x - 1)]! +
182
+ img[(y - 1) * width + (x + 1)]! + 2 * img[y * width + (x + 1)]! + img[(y + 1) * width + (x + 1)]!;
183
+ // Vertical Sobel
184
+ const gy =
185
+ -img[(y - 1) * width + (x - 1)]! - 2 * img[(y - 1) * width + x]! - img[(y - 1) * width + (x + 1)]! +
186
+ img[(y + 1) * width + (x - 1)]! + 2 * img[(y + 1) * width + x]! + img[(y + 1) * width + (x + 1)]!;
187
+ edges[idx] = Math.sqrt(gx * gx + gy * gy);
188
+ }
189
+ }
190
+ return edges;
191
+ }
192
+
193
+ /** Local variance in blocks */
194
+ function localVariance(img: Uint8Array, width: number, height: number, blockSize: number): Float32Array {
195
+ const bw = Math.floor(width / blockSize);
196
+ const bh = Math.floor(height / blockSize);
197
+ const result = new Float32Array(bw * bh);
198
+
199
+ for (let by = 0; by < bh; by++) {
200
+ for (let bx = 0; bx < bw; bx++) {
201
+ let sum = 0, sum2 = 0;
202
+ const n = blockSize * blockSize;
203
+ for (let dy = 0; dy < blockSize; dy++) {
204
+ for (let dx = 0; dx < blockSize; dx++) {
205
+ const val = img[(by * blockSize + dy) * width + (bx * blockSize + dx)]!;
206
+ sum += val;
207
+ sum2 += val * val;
208
+ }
209
+ }
210
+ const mean = sum / n;
211
+ result[by * bw + bx] = sum2 / n - mean * mean;
212
+ }
213
+ }
214
+ return result;
215
+ }
216
+
217
+ /** 256-bin histogram */
218
+ function histogram(img: Uint8Array): Float32Array {
219
+ const hist = new Float32Array(256);
220
+ for (let i = 0; i < img.length; i++) {
221
+ const val = img[i]!;
222
+ hist[val] = (hist[val] ?? 0) + 1;
223
+ }
224
+ return hist;
225
+ }
226
+
227
+ // ============================================================
228
+ // SPATIAL METRICS COMPUTATION
229
+ // ============================================================
230
+
231
+ /**
232
+ * Compute spatial verification metrics
233
+ * Compares expected render vs actual camera capture
234
+ */
235
+ export function computeSpatialMetrics(
236
+ expected: RenderedView,
237
+ actual: CameraFrame,
238
+ thresholds: VerificationThresholds,
239
+ ): SpatialMetrics {
240
+ // Validate dimensions match
241
+ if (expected.width !== actual.width || expected.height !== actual.height) {
242
+ throw new Error(
243
+ `Dimension mismatch: expected ${expected.width}x${expected.height}, ` +
244
+ `actual ${actual.width}x${actual.height}`,
245
+ );
246
+ }
247
+
248
+ // Convert to grayscale for SSIM
249
+ const grayExpected = rgbToGrayscale(expected.rgb, expected.width, expected.height);
250
+ const grayActual = rgbToGrayscale(actual.rgb, actual.width, actual.height);
251
+
252
+ // SSIM: structural similarity [0,1]
253
+ const ssim = computeSSIM(grayExpected, grayActual, expected.width, expected.height);
254
+
255
+ // LPIPS approximation: perceptual dissimilarity [0,1]
256
+ const lpips = approximateLPIPS(grayExpected, grayActual, expected.width, expected.height);
257
+
258
+ // Depth MAE (meters) — only if both have depth
259
+ let depthMAE = 0;
260
+ if (expected.depth && actual.depth) {
261
+ depthMAE = meanAbsoluteError(expected.depth, actual.depth);
262
+ }
263
+
264
+ // Composite score: weighted combination
265
+ // SSIM contributes positively, LPIPS and depth MAE contribute negatively
266
+ const ssimNorm = Math.max(0, ssim); // [0,1]
267
+ const lpipsNorm = 1 - Math.min(1, lpips); // invert: [0,1] where 1=good
268
+ const depthNorm = 1 - Math.min(1, depthMAE / thresholds.maxDepthMAE); // [0,1]
269
+
270
+ const composite = 0.4 * ssimNorm + 0.3 * lpipsNorm + 0.3 * depthNorm;
271
+
272
+ return { ssim, lpips, depthMAE, composite };
273
+ }
274
+
275
+ // ============================================================
276
+ // SPATIAL PROOF GENERATION
277
+ // ============================================================
278
+
279
+ const DEFAULT_THRESHOLDS: VerificationThresholds = {
280
+ minSSIM: 0.75,
281
+ maxLPIPS: 0.25,
282
+ maxDepthMAE: 0.5, // meters
283
+ minComposite: 0.75,
284
+ };
285
+
286
+ /**
287
+ * Generate a spatial proof — cryptographic proof that robot is at claimed location
288
+ */
289
+ export function generateSpatialProof(
290
+ robotId: string,
291
+ claimedPose: Pose,
292
+ actualFrame: CameraFrame,
293
+ expectedRender: RenderedView,
294
+ memoryMerkleRoot: string,
295
+ merkleProof: readonly string[],
296
+ hmacSecret: string,
297
+ thresholds: VerificationThresholds = DEFAULT_THRESHOLDS,
298
+ hardwareAttestation?: string,
299
+ ): SpatialProof {
300
+ // Validate inputs
301
+ if (!robotId) throw new Error('robotId is required');
302
+ if (!hmacSecret || hmacSecret.length < 32) throw new Error('HMAC secret must be at least 32 chars');
303
+ if (!actualFrame.hmac) throw new Error('Actual frame must be HMAC-signed');
304
+
305
+ // Compute spatial metrics
306
+ const metrics = computeSpatialMetrics(expectedRender, actualFrame, thresholds);
307
+
308
+ // Determine if proof passes
309
+ const passed =
310
+ metrics.ssim >= thresholds.minSSIM &&
311
+ metrics.lpips <= thresholds.maxLPIPS &&
312
+ metrics.depthMAE <= thresholds.maxDepthMAE &&
313
+ metrics.composite >= thresholds.minComposite;
314
+
315
+ // Generate nonce for replay prevention
316
+ const nonce = generateNonce(32);
317
+
318
+ // Build proof payload
319
+ const proofId = generateNonce(16);
320
+ const timestamp = Date.now();
321
+
322
+ // Sign the proof (includes all critical fields to prevent tampering)
323
+ const proofKey = deriveKey(hmacSecret, 'spatial-proof');
324
+ const signatureData = Buffer.from(
325
+ [
326
+ proofId,
327
+ robotId,
328
+ timestamp.toString(),
329
+ nonce,
330
+ passed.toString(),
331
+ metrics.composite.toFixed(6),
332
+ memoryMerkleRoot,
333
+ actualFrame.id,
334
+ ].join(':'),
335
+ );
336
+ const signature = hmacSign(signatureData, proofKey);
337
+
338
+ return {
339
+ id: proofId,
340
+ robotId,
341
+ claimedPose,
342
+ actualFrame,
343
+ expectedRender,
344
+ metrics,
345
+ passed,
346
+ memoryMerkleRoot,
347
+ merkleProof,
348
+ timestamp,
349
+ signature,
350
+ nonce,
351
+ hardwareAttestation,
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Verify a spatial proof's integrity (does NOT re-run image comparison)
357
+ * Checks: HMAC signature, nonce freshness, Merkle root validity
358
+ */
359
+ export function verifySpatialProofIntegrity(
360
+ proof: SpatialProof,
361
+ hmacSecret: string,
362
+ maxAgeMs: number = 300_000, // 5 minute max proof age
363
+ ): { valid: boolean; reason?: string } {
364
+ // Check proof age
365
+ const age = Date.now() - proof.timestamp;
366
+ if (age > maxAgeMs) {
367
+ return { valid: false, reason: `Proof expired: age ${age}ms > max ${maxAgeMs}ms` };
368
+ }
369
+ if (age < 0) {
370
+ return { valid: false, reason: 'Proof timestamp is in the future' };
371
+ }
372
+
373
+ // Verify HMAC signature
374
+ const proofKey = deriveKey(hmacSecret, 'spatial-proof');
375
+ const signatureData = Buffer.from(
376
+ [
377
+ proof.id,
378
+ proof.robotId,
379
+ proof.timestamp.toString(),
380
+ proof.nonce,
381
+ proof.passed.toString(),
382
+ proof.metrics.composite.toFixed(6),
383
+ proof.memoryMerkleRoot,
384
+ proof.actualFrame.id,
385
+ ].join(':'),
386
+ );
387
+ const expectedSig = hmacSign(signatureData, proofKey);
388
+ if (expectedSig !== proof.signature) {
389
+ return { valid: false, reason: 'HMAC signature mismatch — proof may be tampered' };
390
+ }
391
+
392
+ return { valid: true };
393
+ }
394
+
395
+ // ============================================================
396
+ // SETTLEMENT
397
+ // ============================================================
398
+
399
+ /**
400
+ * Create a spatial settlement (payment tied to spatial proof)
401
+ * Atomic: either the proof is valid AND payment succeeds, or both fail
402
+ */
403
+ export function createSettlement(
404
+ proof: SpatialProof,
405
+ amount: number,
406
+ currency: string,
407
+ payeeId: string,
408
+ hmacSecret: string,
409
+ ): SpatialSettlement {
410
+ if (amount <= 0) throw new Error('Settlement amount must be positive');
411
+ if (!currency) throw new Error('Currency is required');
412
+ if (!payeeId) throw new Error('Payee ID is required');
413
+
414
+ // Verify proof integrity first
415
+ const integrity = verifySpatialProofIntegrity(proof, hmacSecret);
416
+
417
+ let status: SettlementStatus;
418
+ let failureReason: string | undefined;
419
+
420
+ if (!integrity.valid) {
421
+ status = 'failed' as SettlementStatus;
422
+ failureReason = `Proof integrity check failed: ${integrity.reason}`;
423
+ } else if (!proof.passed) {
424
+ status = 'failed' as SettlementStatus;
425
+ failureReason = `Spatial verification failed: composite score ${proof.metrics.composite.toFixed(3)} < threshold`;
426
+ } else {
427
+ status = 'verified' as SettlementStatus;
428
+ }
429
+
430
+ return {
431
+ id: generateNonce(16),
432
+ proof,
433
+ amount,
434
+ currency,
435
+ status,
436
+ payerRobotId: proof.robotId,
437
+ payeeId,
438
+ initiatedAt: Date.now(),
439
+ settledAt: status === 'verified' ? Date.now() : undefined,
440
+ failureReason,
441
+ };
442
+ }