/** * Native SPZ-4 ingest for GridStamp evidence chain. * * SPZ is Niantic's compressed Gaussian-splat container ("the JPG for 3DGS"). * SPZ 4 (the current format, MIT-licensed at github.com/nianticlabs/spz) * uses a 32-byte plaintext header followed by ZSTD-compressed parallel streams. * * Per the GridStamp native-tools doctrine, this parser is pure Node — no * third-party SPZ libraries. We parse the plaintext header, validate magic * + version, and produce a fingerprinted evidence envelope that slots into * the existing proof chain. Stream decoding (positions / colors / SH) is a * separate concern handled by the consumer when it needs to render. * * Use: ingest a splat capture from a drone, robot, or insurer-side reviewer * and attach the resulting envelope to a SpatialProof as an additional * evidence layer alongside GPS + frame-integrity + Merkle. */ import { createHash } from 'node:crypto'; /** SPZ-4 magic bytes: ASCII "NGSP" interpreted little-endian as a u32. */ export const SPZ_MAGIC = 0x5053474e; /** GZIP magic — used by legacy SPZ 1-3, which we explicitly reject. */ const GZIP_MAGIC = [0x1f, 0x8b] as const; /** Minimum SPZ-4 header size. */ export const SPZ_HEADER_SIZE = 32; /** Currently supported SPZ format version. */ export const SPZ_SUPPORTED_VERSION = 4; /** * Evidence envelope produced from a single SPZ capture. * Designed to be JSON-serializable and embedded inside a SpatialProof. */ export interface SpzEvidence { kind: 'splat_v1'; format: 'spz'; version: number; pointCount: number; shDegree: number; fractionalBits: number; flags: number; streamCount: number; byteSize: number; /** Hex-encoded SHA-256 of the full SPZ blob (canonical fingerprint). */ sha256: string; /** ISO-8601 timestamp claimed by the capturing device (caller-supplied). */ capturedAt?: string; } export type SpzParseResult = | { ok: true; evidence: SpzEvidence } | { ok: false; reason: SpzRejectReason }; export type SpzRejectReason = | 'buffer-too-small' | 'bad-magic' | 'legacy-gzip-spz-not-supported' | `unsupported-version-${number}`; /** * Parse + validate an SPZ-4 blob and produce an evidence envelope. * * Fail-closed: any malformed input returns {ok:false, reason} rather than * throwing, so the caller can choose to drop the splat from the proof * chain without aborting the whole settlement. */ export function parseSpz( buf: Buffer | Uint8Array, opts: { capturedAt?: string } = {}, ): SpzParseResult { const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); if (data.length < SPZ_HEADER_SIZE) { return { ok: false, reason: 'buffer-too-small' }; } // Magic check — legacy gzip-framed SPZ (versions 1-3) starts with 0x1f 0x8b // and is explicitly out-of-scope for v1 of this adapter. if (data[0] === GZIP_MAGIC[0] && data[1] === GZIP_MAGIC[1]) { return { ok: false, reason: 'legacy-gzip-spz-not-supported' }; } const magic = data.readUInt32LE(0); if (magic !== SPZ_MAGIC) { return { ok: false, reason: 'bad-magic' }; } const version = data.readUInt32LE(4); if (version !== SPZ_SUPPORTED_VERSION) { return { ok: false, reason: `unsupported-version-${version}` }; } const pointCount = data.readUInt32LE(8); const shDegree = data.readUInt8(12); const fractionalBits = data.readUInt8(13); const flags = data.readUInt8(14); const streamCount = data.readUInt8(15); // bytes 16..32 hold the TOC offset + reserved bytes; not needed for the envelope. const sha256 = createHash('sha256').update(data).digest('hex'); return { ok: true, evidence: { kind: 'splat_v1', format: 'spz', version, pointCount, shDegree, fractionalBits, flags, streamCount, byteSize: data.length, sha256, capturedAt: opts.capturedAt, }, }; } /** * Throwing variant. Useful inside async pipelines where rejected splats * should abort the surrounding settlement. */ export function parseSpzOrThrow( buf: Buffer | Uint8Array, opts: { capturedAt?: string } = {}, ): SpzEvidence { const r = parseSpz(buf, opts); if (!r.ok) throw new Error(`SPZ parse failed: ${r.reason}`); return r.evidence; }