diff --git a/package.json b/package.json index fac3f39e5..d47b2a0e2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "packageManager": "pnpm@10.9.0", + "packageManager": "pnpm@10.28.1", "engines": { "node": ">=20.19.0" }, @@ -19,7 +19,7 @@ "dev:ssr": "NODE_ENV=development node server.mjs", "build:clean": "rm -rf dist .vite && pnpm run build:ssr", "build": "tsc -b && vite build", - "build:ssr": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && node scripts/ssr-pack.mjs", + "build:ssr": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && vite build --ssr src/entry-server-exports.ts --outDir dist/server && node scripts/ssr-pack.mjs", "lint": "eslint .", "preview": "NODE_ENV=production node server.mjs", "test": "node --test" @@ -27,6 +27,7 @@ "dependencies": { "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.4.0", + "@resvg/resvg-js": "^2.6.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", "blakejs": "^1.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf065d2af..6f88599b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@react-three/fiber': specifier: ^9.4.0 version: 9.5.0(@types/react@19.2.9)(immer@11.1.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.181.2) + '@resvg/resvg-js': + specifier: ^2.6.0 + version: 2.6.2 '@stripe/react-stripe-js': specifier: ^5.4.1 version: 5.4.1(@stripe/stripe-js@8.6.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -548,6 +551,82 @@ packages: react-redux: optional: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rolldown/binding-android-arm64@1.0.0-beta.47': resolution: {integrity: sha512-vPP9/MZzESh9QtmvQYojXP/midjgkkc1E4AdnPPAzQXo668ncHJcVLKjJKzoBdsQmaIvNjrMdsCwES8vTQHRQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2452,6 +2531,57 @@ snapshots: react: 19.2.3 react-redux: 9.2.0(@types/react@19.2.9)(react@19.2.3)(redux@5.0.1) + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/binding-android-arm64@1.0.0-beta.47': optional: true diff --git a/public/phi_og_verified_template.png b/public/phi_og_verified_template.png new file mode 100644 index 000000000..7699e29f9 Binary files /dev/null and b/public/phi_og_verified_template.png differ diff --git a/public/sw.js b/public/sw.js index c2146ce11..f315492e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -/* KAIKLOK SW — offline-first + route mapping for /s/* (sigil links) +/* PHI NETWORK SW — offline-first + route mapping for /s/* (sigil links) - Canonical service worker (legacy /service-worker.js removed to avoid conflicts) - Instant offline boot (app-shell) - Seeds known sigil links from /sigils-index.json (optional) @@ -14,7 +14,7 @@ // Update this version string manually to keep the app + cache versions in sync. // The value is forwarded to the UI via the service worker "SW_ACTIVATED" message. -const APP_VERSION = "41.9.8"; // update on release +const APP_VERSION = "42.0.0"; // update on release const VERSION = new URL(self.location.href).searchParams.get("v") || APP_VERSION; // derived from build const PREFIX = "PHINETWORK"; diff --git a/server.mjs b/server.mjs index 0c18e175a..4333c44f8 100644 --- a/server.mjs +++ b/server.mjs @@ -28,6 +28,27 @@ const mimeTypes = { ".map": "application/json", }; +const OG_PATH_PREFIX = "/og/v/verified/"; +const OG_CACHE_CONTROL = "public, max-age=0, s-maxage=31536000, immutable"; +const OG_CACHE_TTL_MS = 10 * 60 * 1000; +const OG_CACHE_MAX_ENTRIES = 512; + +const escapeHtml = (value) => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +const shortPhiKey = (phiKey) => { + const trimmed = String(phiKey || "").trim(); + if (trimmed.length <= 14) return trimmed; + return `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}`; +}; + +const stripQuotes = (value) => String(value || "").replace(/\"/g, ""); + const sendFile = (res, filePath, cacheControl) => { const ext = path.extname(filePath).toLowerCase(); res.statusCode = 200; @@ -69,126 +90,286 @@ async function createServer() { } let dataCache = null; + let ogCache = null; + let ogModulePromise = null; - const server = createHttpServer((req, res) => { - if (!req.url) { + const loadOgModule = async () => { + if (!ogModulePromise) { + ogModulePromise = (async () => { + const mod = isProd + ? await import(resolvePath("dist/server/entry-server-exports.js")) + : await vite.ssrLoadModule("/src/entry-server-exports.ts"); + return { + renderVerifiedOgPng: mod.renderVerifiedOgPng, + renderNotFoundOgPng: mod.renderNotFoundOgPng, + getCapsuleByHash: mod.getCapsuleByHash, + getCapsuleByVerifierSlug: mod.getCapsuleByVerifierSlug, + OgLruTtlCache: mod.OgLruTtlCache, + }; + })(); + } + return ogModulePromise; + }; + + const buildVerifiedMeta = async (requestUrl, origin) => { + const pathname = requestUrl.pathname || "/"; + if (!pathname.startsWith("/s/") && !pathname.startsWith("/verify/")) return ""; + + const og = await loadOgModule(); + let slug; + try { + slug = decodeURIComponent(pathname.replace(/^\/(s|verify)\//, "")); + } catch { + return ""; + } + let record = null; + if (pathname.startsWith("/verify/")) { + record = og.getCapsuleByVerifierSlug(slug) ?? og.getCapsuleByHash(slug); + } else { + record = og.getCapsuleByHash(slug); + } + if (!record) return ""; + + const ogImageUrl = `${origin}/og/v/verified/${encodeURIComponent(record.capsuleHash)}/${encodeURIComponent( + String(record.verifiedAtPulse), + )}.png`; + const title = `VERIFIED • Steward @ Pulse ${record.verifiedAtPulse} • ΦKey ${shortPhiKey(record.phikey)}`; + const description = `KAS ${record.kasOk ? "✓" : "×"} • G16 ${record.g16Ok ? "✓" : "×"} • Proof of Breath™`; + + return [ + ``, + ``, + ``, + ``, + ``, + ``, + ].join(""); + }; + + const handleOgRoute = async (req, res) => { + const url = new URL(req.url, "http://localhost"); + let pathname; + try { + pathname = decodeURIComponent(url.pathname); + } catch { res.statusCode = 400; res.end("Bad Request"); - return; + return true; } - if (isProd) { - const clientRoot = resolvePath("dist/client"); - if (tryServeStatic(req, res, clientRoot)) return; + if (!pathname.startsWith(OG_PATH_PREFIX)) return false; + const suffix = pathname.slice(OG_PATH_PREFIX.length); + const cleaned = suffix.endsWith(".png") ? suffix.slice(0, -4) : suffix; + const [capsuleHash, verifiedAtPulseRaw, extra] = cleaned.split("/"); + + if (!capsuleHash || extra) { + res.statusCode = 400; + res.end("Bad Request"); + return true; } - const runSsr = async () => { - const url = req.url || "/"; - const origin = (() => { - const host = req.headers.host || "localhost"; - const proto = req.headers["x-forwarded-proto"] || "http"; - if (Array.isArray(proto)) return `${proto[0]}://${host}`; - return `${proto}://${host}`; - })(); - const requestUrl = new URL(url, origin); + const og = await loadOgModule(); + if (!ogCache) { + ogCache = new og.OgLruTtlCache({ maxEntries: OG_CACHE_MAX_ENTRIES, ttlMs: OG_CACHE_TTL_MS }); + } - const templatePath = isProd ? "dist/client/index.html" : "index.html"; - let template = await fs.readFile(resolvePath(templatePath), "utf-8"); - if (!isProd && vite) { - template = await vite.transformIndexHtml(url, template); + const record = og.getCapsuleByHash(capsuleHash); + const isNotFound = !record; + if (!verifiedAtPulseRaw) { + if (record) { + const redirectUrl = `${OG_PATH_PREFIX}${encodeURIComponent(record.capsuleHash)}/${encodeURIComponent( + String(record.verifiedAtPulse), + )}.png`; + res.statusCode = 302; + res.setHeader("Location", redirectUrl); + res.setHeader("Cache-Control", OG_CACHE_CONTROL); + res.end(); + return true; } + } - const [head, tail] = template.split(""); - const { render, safeJsonStringify, stableJsonStringify, buildSnapshotEntries, LruTtlCache } = isProd - ? await import(resolvePath("dist/server/entry-server.js")) - : await vite.ssrLoadModule("/src/entry-server.tsx"); - - if (!dataCache) { - dataCache = new LruTtlCache({ maxEntries: 256 }); + if (record && verifiedAtPulseRaw) { + const requestedPulse = Number(verifiedAtPulseRaw); + if (!Number.isFinite(requestedPulse) || requestedPulse !== record.verifiedAtPulse) { + const redirectUrl = `${OG_PATH_PREFIX}${encodeURIComponent(record.capsuleHash)}/${encodeURIComponent( + String(record.verifiedAtPulse), + )}.png`; + res.statusCode = 302; + res.setHeader("Location", redirectUrl); + res.setHeader("Cache-Control", OG_CACHE_CONTROL); + res.end(); + return true; } + } - const snapshotEntries = await buildSnapshotEntries(requestUrl, dataCache); - const snapshot = { - version: "v1", - url: `${requestUrl.pathname}${requestUrl.search}`, - createdAtMs: Date.now(), - data: snapshotEntries, - }; + const cacheKey = record ? `${capsuleHash}:${record.verifiedAtPulse}` : `notfound:${capsuleHash}`; + let cached = ogCache.get(cacheKey); + if (!cached) { + const pngBuffer = record ? og.renderVerifiedOgPng(record) : og.renderNotFoundOgPng(capsuleHash); + const etag = createHash("sha256").update(pngBuffer).digest("hex"); + cached = { pngBuffer, etag }; + ogCache.set(cacheKey, cached); + } - const etagSource = { ...snapshot, createdAtMs: 0 }; - const etag = createHash("sha256").update(stableJsonStringify(etagSource)).digest("hex"); - snapshot.meta = { etag }; + const ifNoneMatch = req.headers["if-none-match"]; + if (ifNoneMatch && stripQuotes(ifNoneMatch) === cached.etag) { + res.statusCode = 304; + res.setHeader("ETag", `"${cached.etag}"`); + res.setHeader("Cache-Control", OG_CACHE_CONTROL); + if (isNotFound) { + res.setHeader("X-OG-Not-Found", "1"); + } + res.end(); + return true; + } - const ifNoneMatch = req.headers["if-none-match"]; - const cacheControl = "public, max-age=0, s-maxage=30, stale-while-revalidate=300"; + res.statusCode = isNotFound ? 404 : 200; + res.setHeader("Content-Type", "image/png"); + res.setHeader("Content-Length", cached.pngBuffer.length); + res.setHeader("ETag", `"${cached.etag}"`); + res.setHeader("Cache-Control", OG_CACHE_CONTROL); + if (isNotFound) { + res.setHeader("X-OG-Not-Found", "1"); + } + res.end(cached.pngBuffer); + return true; + }; - if (ifNoneMatch && ifNoneMatch.replace(/\"/g, "") === etag) { - res.statusCode = 304; - res.setHeader("ETag", `"${etag}"`); - res.setHeader("Cache-Control", cacheControl); - res.end(); + const server = createHttpServer((req, res) => { + const handleRequest = async () => { + if (!req.url) { + res.statusCode = 400; + res.end("Bad Request"); return; } - const initialData = { url }; - const snapshotScript = ``; - const snapshotEtag = ``; - const ssrHead = `${snapshotScript}${snapshotEtag}`; - const htmlHead = head.replace("", ssrHead).replace("
", "
"); - - let didError = false; - const bodyStream = new PassThrough(); - bodyStream.on("end", () => { - res.write(tail); - res.end(); - }); + if (await handleOgRoute(req, res)) return; - const { pipe, abort } = render(url, snapshot, { - onShellReady() { - res.statusCode = didError ? 500 : 200; - res.setHeader("Content-Type", "text/html"); - res.setHeader("Cache-Control", cacheControl); + if (isProd) { + const clientRoot = resolvePath("dist/client"); + if (tryServeStatic(req, res, clientRoot)) return; + } + + const runSsr = async () => { + const url = req.url || "/"; + const origin = (() => { + const host = req.headers.host || "localhost"; + const proto = req.headers["x-forwarded-proto"] || "http"; + if (Array.isArray(proto)) return `${proto[0]}://${host}`; + return `${proto}://${host}`; + })(); + const requestUrl = new URL(url, origin); + + const templatePath = isProd ? "dist/client/index.html" : "index.html"; + let template = await fs.readFile(resolvePath(templatePath), "utf-8"); + if (!isProd && vite) { + template = await vite.transformIndexHtml(url, template); + } + + const [head, tail] = template.split(""); + const { render } = isProd + ? await import(resolvePath("dist/server/entry-server.js")) + : await vite.ssrLoadModule("/src/entry-server.tsx"); + const { safeJsonStringify, stableJsonStringify, buildSnapshotEntries, LruTtlCache } = isProd + ? await import(resolvePath("dist/server/entry-server-exports.js")) + : await vite.ssrLoadModule("/src/entry-server-exports.ts"); + + if (!dataCache) { + dataCache = new LruTtlCache({ maxEntries: 256 }); + } + + const snapshotEntries = await buildSnapshotEntries(requestUrl, dataCache); + const snapshot = { + version: "v1", + url: `${requestUrl.pathname}${requestUrl.search}`, + createdAtMs: Date.now(), + data: snapshotEntries, + }; + + const etagSource = { ...snapshot, createdAtMs: 0 }; + const etag = createHash("sha256").update(stableJsonStringify(etagSource)).digest("hex"); + snapshot.meta = { etag }; + + const ifNoneMatch = req.headers["if-none-match"]; + const cacheControl = "public, max-age=0, s-maxage=30, stale-while-revalidate=300"; + + if (ifNoneMatch && ifNoneMatch.replace(/\"/g, "") === etag) { + res.statusCode = 304; res.setHeader("ETag", `"${etag}"`); - res.write(htmlHead); - pipe(bodyStream); - bodyStream.pipe(res, { end: false }); - }, - onShellError(error) { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html"); - res.end(template.replace("", "")); - console.error(error); - }, - onAllReady() { - // handled by stream end - }, - onError(error) { - didError = true; - console.error(error); - }, - }); + res.setHeader("Cache-Control", cacheControl); + res.end(); + return; + } - setTimeout(() => abort(), 15000); - }; + const initialData = { url }; + const snapshotScript = ``; + const snapshotEtag = ``; + const ogMeta = await buildVerifiedMeta(requestUrl, origin); + const ssrHead = `${ogMeta}${snapshotScript}${snapshotEtag}`; + const htmlHead = head.replace("", ssrHead).replace("
", "
"); + + let didError = false; + const bodyStream = new PassThrough(); + bodyStream.on("end", () => { + res.write(tail); + res.end(); + }); - if (!isProd && vite) { - vite.middlewares(req, res, () => { - runSsr().catch((error) => { - vite.ssrFixStacktrace(error); - console.error(error); - res.statusCode = 500; - res.end("Internal Server Error"); + const { pipe, abort } = render(url, snapshot, { + onShellReady() { + res.statusCode = didError ? 500 : 200; + res.setHeader("Content-Type", "text/html"); + res.setHeader("Cache-Control", cacheControl); + res.setHeader("ETag", `"${etag}"`); + res.write(htmlHead); + pipe(bodyStream); + bodyStream.pipe(res, { end: false }); + }, + onShellError(error) { + res.statusCode = 500; + res.setHeader("Content-Type", "text/html"); + res.end(template.replace("", "")); + console.error(error); + }, + onAllReady() { + // handled by stream end + }, + onError(error) { + didError = true; + console.error(error); + }, }); + + setTimeout(() => abort(), 15000); + }; + + if (!isProd && vite) { + vite.middlewares(req, res, () => { + runSsr().catch((error) => { + vite.ssrFixStacktrace(error); + console.error(error); + res.statusCode = 500; + res.end("Internal Server Error"); + }); + }); + return; + } + + runSsr().catch((error) => { + console.error(error); + res.statusCode = 500; + res.end("Internal Server Error"); }); - return; - } + }; - runSsr().catch((error) => { + handleRequest().catch((error) => { console.error(error); - res.statusCode = 500; - res.end("Internal Server Error"); + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } }); }); diff --git a/src/assets/phi.svg b/src/assets/phi.svg new file mode 100644 index 000000000..46a0712ee --- /dev/null +++ b/src/assets/phi.svg @@ -0,0 +1,478 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/KaiVoh/KaiVohApp.tsx b/src/components/KaiVoh/KaiVohApp.tsx index 9a5ce978b..a85911498 100644 --- a/src/components/KaiVoh/KaiVohApp.tsx +++ b/src/components/KaiVoh/KaiVohApp.tsx @@ -43,13 +43,20 @@ import VerifierFrame from "./VerifierFrame"; import { buildVerifierSlug, buildVerifierUrl, - buildBundleUnsigned, - hashBundle, + buildBundleRoot, + buildZkPublicInputs, + computeBundleHash, hashProofCapsuleV1, hashSvgText, + normalizeProofBundleZkCurves, normalizeChakraDay, PROOF_CANON, + PROOF_BINDINGS, PROOF_HASH_ALG, + ZK_PUBLIC_INPUTS_CONTRACT, + ZK_STATEMENT_BINDING, + ZK_STATEMENT_ENCODING, + ZK_STATEMENT_DOMAIN, type ProofCapsuleV1, } from "./verifierProof"; @@ -812,25 +819,45 @@ function KaiVohFlow(): ReactElement { ? (mergedMetadata as { shareUrl?: string }).shareUrl : undefined; + const zkStatement = zkPoseidonHash + ? { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT, + encoding: ZK_STATEMENT_ENCODING, + } + : undefined; + const zkMeta = zkPoseidonHash + ? { + protocol: "groth16", + scheme: "groth16-poseidon", + circuitId: "sigil_proof", + } + : undefined; + const normalizedZk = normalizeProofBundleZkCurves({ zkProof, zkMeta, proofHints }); + zkProof = normalizedZk.zkProof; + const zkMetaNormalized = normalizedZk.zkMeta; const proofBundleBase = { v: "KPB-1", hashAlg: PROOF_HASH_ALG, canon: PROOF_CANON, + bindings: PROOF_BINDINGS, + zkStatement, proofCapsule: capsule, capsuleHash, svgHash, - shareUrl, - verifierUrl, zkPoseidonHash, zkProof, + zkPublicInputs: zkPoseidonHash ? buildZkPublicInputs(zkPoseidonHash) : zkPublicInputs, + zkMeta: zkMetaNormalized, + }; + const transport = { + shareUrl, + verifierUrl, proofHints, - zkPublicInputs, }; - const bundleUnsigned = buildBundleUnsigned({ - ...proofBundleBase, - authorSig: null, - }); - bundleHash = await hashBundle(bundleUnsigned); + const bundleRoot = buildBundleRoot(proofBundleBase); + bundleHash = await computeBundleHash(bundleRoot); try { await ensurePasskey(proofPhiKey); authorSig = await signBundleHash(proofPhiKey, bundleHash); @@ -842,8 +869,11 @@ function KaiVohFlow(): ReactElement { } const proofBundle = { ...proofBundleBase, + bundleRoot, bundleHash, authorSig, + transport, + proofHints, }; if (authorSig?.v === "KAS-1") { const authUrl = shareUrl || verifierUrl; diff --git a/src/components/KaiVoh/verifierProof.ts b/src/components/KaiVoh/verifierProof.ts index 74a5cbf13..d36733c9a 100644 --- a/src/components/KaiVoh/verifierProof.ts +++ b/src/components/KaiVoh/verifierProof.ts @@ -14,13 +14,74 @@ import type { ChakraDay } from "../../utils/kai_pulse"; import { jcsCanonicalize } from "../../utils/jcs"; -import { sha256Hex } from "../../utils/sha256"; +import { base64UrlEncode, hexToBytes, sha256Hex } from "../../utils/sha256"; import { svgCanonicalForHash } from "../../utils/svgProof"; import type { AuthorSig } from "../../utils/authorSig"; +import type { ReceiveSig } from "../../utils/webauthnReceive"; +import type { OwnerKeyDerivation } from "../../utils/ownerPhiKey"; +import type { VerificationCache } from "../../utils/verificationCache"; +import type { VerificationReceipt, VerificationSig } from "../../utils/verificationReceipt"; +import { VERIFICATION_BUNDLE_VERSION } from "../../utils/verificationVersion"; + +export type { VerificationCache } from "../../utils/verificationCache"; export const PROOF_HASH_ALG = "sha256" as const; +// JCS = RFC 8785 canonical JSON. export const PROOF_CANON = "JCS" as const; export const PROOF_METADATA_ID = "kai-voh-proof" as const; +export { VERIFICATION_BUNDLE_VERSION }; +export const PROOF_BINDINGS = { + capsuleHashOf: "JCS(proofCapsule)", + bundleHashOf: "sha256(JCS(bundleRoot))", + authorChallengeOf: "base64url(bytes(bundleHash))", +} as const; +export const ZK_STATEMENT_BINDING = "Poseidon(capsuleHash|svgHash|domainTag)" as const; +export const ZK_STATEMENT_DOMAIN = "kairos.sigil.zk.v1" as const; +/** + * h2f mapping for Poseidon statement (documentation only): + * - capsuleHash/svgHash (hex sha256, 32 bytes): BigInt("0x" + hex) mod FIELD_PRIME + * - domainTag (UTF-8 string): BigInt("0x" + sha256(utf8(tag))) mod FIELD_PRIME + */ +export const ZK_STATEMENT_ENCODING = { + arity: 3, + inputs: ["capsuleHash", "svgHash", "domainTag"], + interpretation: "field_elements", + fieldMap: "h2f", + poseidon: "Poseidon([h2f(capsuleHash), h2f(svgHash), h2f(domainTag)])", +} as const; +export const ZK_PUBLIC_INPUTS_CONTRACT = { + arity: 2, + invariant: "publicInputs[0] == publicInputs[1]", + meaning: "Both entries equal H where H = Poseidon(capsuleHash|svgHash|domainTag)", +} as const; + +export type VerificationSource = "local" | "pbi"; +export type ProofBundleBindings = typeof PROOF_BINDINGS; +export type ZkPublicInputsContract = typeof ZK_PUBLIC_INPUTS_CONTRACT; +export type ZkStatementEncoding = typeof ZK_STATEMENT_ENCODING; +export type ZkStatement = { + publicInputOf: typeof ZK_STATEMENT_BINDING; + domainTag: string; + publicInputsContract?: ZkPublicInputsContract; + encoding?: ZkStatementEncoding; +}; +export type ZkCurve = "bn128" | "BLS12-381"; +export type ZkMeta = Readonly<{ + protocol?: string; + curve?: ZkCurve; + curveAliases?: string[]; + scheme?: string; + circuitId?: string; + vkHash?: string; + warnings?: string[]; +}>; +export type ProofBundleTransport = Readonly<{ + shareUrl?: string; + verifierUrl?: string; + verifiedAtPulse?: number; + verifier?: VerificationSource; + proofHints?: unknown; +}>; /* -------------------------------------------------------------------------- */ /* Base URL */ @@ -65,14 +126,22 @@ export function shortKaiSig10(sig: string): string { return safe.length > 10 ? safe.slice(0, 10) : safe; } -export function buildVerifierSlug(pulse: number, kaiSignature: string): string { +export function buildVerifierSlug(pulse: number, kaiSignature: string, verifiedAtPulse?: number): string { const shortSig = shortKaiSig10(kaiSignature); + if (verifiedAtPulse != null && Number.isFinite(verifiedAtPulse)) { + return `${pulse}-${shortSig}-${verifiedAtPulse}`; + } return `${pulse}-${shortSig}`; } -export function buildVerifierUrl(pulse: number, kaiSignature: string, verifierBaseUrl?: string): string { +export function buildVerifierUrl( + pulse: number, + kaiSignature: string, + verifierBaseUrl?: string, + verifiedAtPulse?: number, +): string { const base = (verifierBaseUrl ?? defaultHostedVerifierBaseUrl()).replace(/\/+$/, ""); - const slug = encodeURIComponent(buildVerifierSlug(pulse, kaiSignature)); + const slug = encodeURIComponent(buildVerifierSlug(pulse, kaiSignature, verifiedAtPulse)); return `${base}/${slug}`; } @@ -115,6 +184,31 @@ export function normalizeChakraDay(v?: string): ChakraDay | undefined { return CHAKRA_MAP[k]; } +/* -------------------------------------------------------------------------- */ +/* ZK curve normalization */ +/* -------------------------------------------------------------------------- */ + +export function normalizeZkCurve(curve: unknown): ZkCurve | undefined { + if (curve == null || typeof curve !== "string") return undefined; + const trimmed = curve.trim(); + if (!trimmed) return undefined; + const normalized = trimmed.toLowerCase(); + if (normalized === "bn128" || normalized === "altbn128" || normalized === "bn254") { + return "bn128"; + } + if (normalized === "bls12-381") { + return "BLS12-381"; + } + return undefined; +} + +export function inferZkCurveFromContext(params: { protocol?: string; scheme?: string; circuitId?: string }): ZkCurve | undefined { + if (params.protocol === "groth16" && params.scheme === "groth16-poseidon" && params.circuitId === "sigil_proof") { + return "bn128"; + } + return undefined; +} + /* -------------------------------------------------------------------------- */ /* Proof Capsule Hash (KPV-1) */ /* -------------------------------------------------------------------------- */ @@ -166,29 +260,315 @@ export async function hashSvgText(svgText: string): Promise { } export type ProofBundleLike = { + mode?: "origin" | "receive"; + originBundleHash?: string; + receiveBundleHash?: string; + originAuthorSig?: AuthorSig | null; + receiveSig?: ReceiveSig | null; + receivePulse?: number; + ownerPhiKey?: string; + ownerKeyDerivation?: OwnerKeyDerivation; hashAlg?: string; canon?: string; + bindings?: ProofBundleBindings; + zkStatement?: ZkStatement; + zkScheme?: string; proofCapsule?: ProofCapsuleV1; capsuleHash?: string; svgHash?: string; shareUrl?: string; verifierUrl?: string; + verifier?: VerificationSource; + verificationVersion?: string; + verifiedAtPulse?: number; zkPoseidonHash?: string; zkProof?: unknown; proofHints?: unknown; zkPublicInputs?: unknown; + zkMeta?: ZkMeta; + verificationCache?: VerificationCache; + cacheKey?: string; + receipt?: VerificationReceipt; + receiptHash?: string; + verificationSig?: VerificationSig; + transport?: ProofBundleTransport; + bundleRoot?: BundleRoot; authorSig?: AuthorSig | null; bundleHash?: string; - receiveSig?: unknown; + zkVerified?: boolean; v?: string; [key: string]: unknown; }; type JcsValue = string | number | boolean | null | JcsValue[] | { [k: string]: JcsValue }; +export type BundleRoot = Readonly<{ + v?: string; + hashAlg?: string; + canon?: string; + bindings?: ProofBundleBindings; + zkStatement?: ZkStatement; + proofCapsule?: ProofCapsuleV1; + capsuleHash?: string; + svgHash?: string; + zkPoseidonHash?: string; + zkProof?: unknown; + zkPublicInputs?: unknown; + zkMeta?: ZkMeta; +}>; + +export type NormalizedBundle = Readonly<{ + mode?: "origin" | "receive"; + originBundleHash?: string; + receiveBundleHash?: string; + originAuthorSig?: AuthorSig | null; + receiveSig?: ReceiveSig | null; + receivePulse?: number; + ownerPhiKey?: string; + ownerKeyDerivation?: OwnerKeyDerivation; + proofCapsule?: ProofCapsuleV1; + capsuleHash?: string; + svgHash?: string; + bundleRoot?: BundleRoot; + bundleHash?: string; + authorSig?: AuthorSig | null; + zkProof?: unknown; + zkPublicInputs?: unknown; + bindings?: ProofBundleBindings; + zkStatement?: ZkStatement; + zkMeta?: ZkMeta; + verificationCache?: VerificationCache; + transport?: ProofBundleTransport; + zkPoseidonHash?: string; + cacheKey?: string; + receipt?: VerificationReceipt; + receiptHash?: string; + verificationSig?: VerificationSig; +}>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * IMPORTANT: + * When we "parse" verificationCache from unknown bundles, we treat it as a record + * to safely read properties without TS inferring {}. + */ +type VerificationCacheRecord = Partial & Record; + +type ZkProofWithCurve = Readonly & { curve?: ZkCurve }>; + +export function normalizeProofBundleZkCurves(input: { + zkProof?: unknown; + zkMeta?: ZkMeta; + bundleRoot?: BundleRoot; + proofHints?: unknown; + zkScheme?: string; +}): { zkProof?: unknown; zkMeta?: ZkMeta; bundleRoot?: BundleRoot; curve?: ZkCurve } { + const { zkProof, zkMeta, bundleRoot } = input; + const proofRecord = isRecord(zkProof) ? zkProof : undefined; + const rootRecord = isRecord(bundleRoot) ? bundleRoot : undefined; + const rootProofRecord = rootRecord && isRecord(rootRecord.zkProof) ? rootRecord.zkProof : undefined; + + const proofCurve = normalizeZkCurve(proofRecord?.curve); + const metaCurveRaw = typeof zkMeta?.curve === "string" ? zkMeta.curve.trim() : undefined; + const metaCurve = normalizeZkCurve(metaCurveRaw); + const rootProofCurve = normalizeZkCurve(rootProofRecord?.curve); + const rootMetaRaw = + rootRecord && isRecord(rootRecord.zkMeta) && typeof (rootRecord.zkMeta as Record).curve === "string" + ? String((rootRecord.zkMeta as Record).curve).trim() + : undefined; + const rootMetaCurve = normalizeZkCurve(rootMetaRaw); + + const proofPoints = hasProofPoints(proofRecord) || hasProofPoints(rootProofRecord); + const protocol = + typeof proofRecord?.protocol === "string" + ? (proofRecord.protocol as string) + : typeof zkMeta?.protocol === "string" + ? zkMeta.protocol + : proofPoints + ? "groth16" + : undefined; + const scheme = + typeof zkMeta?.scheme === "string" + ? zkMeta.scheme + : typeof input.zkScheme === "string" + ? input.zkScheme + : isRecord(input.proofHints) && typeof input.proofHints.scheme === "string" + ? (input.proofHints.scheme as string) + : undefined; + const circuitId = typeof zkMeta?.circuitId === "string" ? zkMeta.circuitId : undefined; + const inferredCurve = inferZkCurveFromContext({ protocol, scheme, circuitId }); + + const effectiveProofCurve = proofCurve ?? rootProofCurve; + + const normalizeProof = (proof: Record, curve?: ZkCurve): ZkProofWithCurve => { + const normalized: Record = { ...proof }; + if (curve) { + normalized.curve = curve; + } else if ("curve" in normalized) { + delete normalized.curve; + } + return normalized as ZkProofWithCurve; + }; + + const applyCurveAliases = (curve?: ZkCurve): string[] | undefined => { + if (curve === "bn128") return ["bn254", "altbn128"]; + return undefined; + }; + + const normalizeMeta = (meta: ZkMeta, curve: ZkCurve, warning?: string): ZkMeta => { + const warnings = warning + ? Array.isArray(meta.warnings) + ? [...meta.warnings, warning] + : [warning] + : meta.warnings; + const curveAliases = applyCurveAliases(curve) ?? meta.curveAliases; + return { + ...meta, + curve, + curveAliases, + warnings: warnings && warnings.length ? warnings : undefined, + }; + }; + + let normalizedProof = proofRecord ? normalizeProof(proofRecord, effectiveProofCurve) : zkProof; + let normalizedMeta = zkMeta; + let normalizedRoot = rootRecord; + + if (effectiveProofCurve) { + if (zkMeta) { + const warning = + metaCurveRaw && metaCurve !== effectiveProofCurve + ? `curve_mismatch_corrected meta=${metaCurveRaw} proof=${effectiveProofCurve}` + : undefined; + normalizedMeta = normalizeMeta(zkMeta, effectiveProofCurve, warning); + } + if (rootRecord) { + const rootMeta = + isRecord(rootRecord.zkMeta) && rootRecord.zkMeta ? (rootRecord.zkMeta as ZkMeta) : undefined; + const rootWarning = + rootMetaRaw && rootMetaCurve !== effectiveProofCurve + ? `curve_mismatch_corrected meta=${rootMetaRaw} proof=${effectiveProofCurve}` + : undefined; + const normalizedRootMeta = rootMeta ? normalizeMeta(rootMeta, effectiveProofCurve, rootWarning) : rootMeta; + normalizedRoot = { + ...rootRecord, + zkProof: rootProofRecord ? normalizeProof(rootProofRecord, effectiveProofCurve) : rootRecord.zkProof, + zkMeta: normalizedRootMeta, + }; + } + } else if (proofPoints) { + if (metaCurve) { + if (zkMeta) normalizedMeta = normalizeMeta(zkMeta, metaCurve); + if (rootRecord) { + const rootMeta = + isRecord(rootRecord.zkMeta) && rootRecord.zkMeta ? (rootRecord.zkMeta as ZkMeta) : undefined; + normalizedRoot = { + ...rootRecord, + zkMeta: rootMeta ? normalizeMeta(rootMeta, metaCurve) : rootMeta, + zkProof: rootProofRecord ? normalizeProof(rootProofRecord, rootProofCurve) : rootRecord.zkProof, + }; + } + if (proofRecord) normalizedProof = normalizeProof(proofRecord, undefined); + } else if (inferredCurve) { + if (proofRecord) normalizedProof = normalizeProof(proofRecord, inferredCurve); + if (zkMeta) { + normalizedMeta = normalizeMeta(zkMeta, inferredCurve); + } else { + normalizedMeta = { curve: inferredCurve, curveAliases: applyCurveAliases(inferredCurve) }; + } + if (rootRecord) { + const rootMeta = + isRecord(rootRecord.zkMeta) && rootRecord.zkMeta ? (rootRecord.zkMeta as ZkMeta) : undefined; + const normalizedRootMeta = rootMeta ? normalizeMeta(rootMeta, inferredCurve) : rootMeta; + normalizedRoot = { + ...rootRecord, + zkProof: rootProofRecord ? normalizeProof(rootProofRecord, inferredCurve) : rootRecord.zkProof, + zkMeta: normalizedRootMeta, + }; + } + } else if (proofRecord) { + normalizedProof = normalizeProof(proofRecord, undefined); + } + } else { + if (zkMeta && metaCurve) normalizedMeta = normalizeMeta(zkMeta, metaCurve); + if (rootRecord && metaCurve) { + const rootMeta = + isRecord(rootRecord.zkMeta) && rootRecord.zkMeta ? (rootRecord.zkMeta as ZkMeta) : undefined; + normalizedRoot = { + ...rootRecord, + zkMeta: rootMeta ? normalizeMeta(rootMeta, metaCurve) : rootMeta, + zkProof: rootProofRecord ? normalizeProof(rootProofRecord, rootProofCurve) : rootRecord.zkProof, + }; + } + if (proofRecord) normalizedProof = normalizeProof(proofRecord, undefined); + } + + return { + zkProof: normalizedProof, + zkMeta: normalizedMeta, + bundleRoot: normalizedRoot, + curve: effectiveProofCurve ?? metaCurve ?? inferredCurve, + }; +} + +function dropUndefined>(value: T): T { + const entries = Object.entries(value).filter((entry) => entry[1] !== undefined); + return Object.fromEntries(entries) as T; +} + +function withZkStatementDefaults(statement: ZkStatement): ZkStatement { + let next = statement; + if (!next.publicInputsContract) { + next = { ...next, publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT }; + } + if (!next.encoding) { + next = { ...next, encoding: ZK_STATEMENT_ENCODING }; + } + return next; +} + +function normalizeCanonLabel(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.toLowerCase() === "jcs") return PROOF_CANON; + if (/sorted keys|utf-8|no whitespace/i.test(trimmed)) return PROOF_CANON; + return trimmed; +} + +export function buildZkPublicInputs(poseidonHash?: string): string[] | undefined { + if (typeof poseidonHash !== "string") return undefined; + const trimmed = poseidonHash.trim(); + if (!trimmed) return undefined; + return [trimmed, trimmed]; +} + +function normalizeBundleRoot(root: Record): BundleRoot { + const bindings = isRecord(root.bindings) ? (root.bindings as ProofBundleBindings) : undefined; + const zkStatement = isRecord(root.zkStatement) ? withZkStatementDefaults(root.zkStatement as ZkStatement) : undefined; + const zkMeta = isRecord(root.zkMeta) ? (root.zkMeta as ZkMeta) : undefined; + return dropUndefined({ + v: typeof root.v === "string" ? root.v : undefined, + hashAlg: typeof root.hashAlg === "string" ? root.hashAlg : undefined, + canon: normalizeCanonLabel(root.canon), + bindings, + zkStatement, + proofCapsule: isRecord(root.proofCapsule) ? (root.proofCapsule as ProofCapsuleV1) : undefined, + capsuleHash: typeof root.capsuleHash === "string" ? root.capsuleHash : undefined, + svgHash: typeof root.svgHash === "string" ? root.svgHash : undefined, + zkPoseidonHash: typeof root.zkPoseidonHash === "string" ? root.zkPoseidonHash : undefined, + zkProof: "zkProof" in root ? root.zkProof : undefined, + zkPublicInputs: "zkPublicInputs" in root ? root.zkPublicInputs : undefined, + zkMeta, + }); +} + /** * Build an unsigned version of the bundle for hashing: - * - strips any existing bundleHash / authorSig / receiveSig + * - strips any existing bundleHash / authorSig / receiveSig / receiveBundleHash / ownerPhiKey / ownerKeyDerivation * - forces authorSig to null to produce a stable canonical hash input */ export function buildBundleUnsigned(bundle: ProofBundleLike): Record { @@ -198,6 +578,9 @@ export function buildBundleUnsigned(bundle: ProofBundleLike): Record).bundleHash; delete (rest as Record).authorSig; delete (rest as Record).receiveSig; + delete (rest as Record).receiveBundleHash; + delete (rest as Record).ownerPhiKey; + delete (rest as Record).ownerKeyDerivation; // force stable unsigned form return { ...rest, authorSig: null }; @@ -207,6 +590,230 @@ export async function hashBundle(bundleUnsigned: Record): Promi return await sha256Hex(jcsCanonicalize(bundleUnsigned as JcsValue)); } +export function buildBundleRoot(bundle: ProofBundleLike): BundleRoot { + const bindings = bundle.bindings ?? PROOF_BINDINGS; + const hashAlg = typeof bundle.hashAlg === "string" ? bundle.hashAlg : PROOF_HASH_ALG; + const canon = normalizeCanonLabel(bundle.canon) ?? PROOF_CANON; + const zkStatementBase = + bundle.zkStatement ?? + (bundle.zkPoseidonHash + ? { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + encoding: ZK_STATEMENT_ENCODING, + } + : undefined); + const zkStatement = zkStatementBase ? withZkStatementDefaults(zkStatementBase) : undefined; + + const normalizedZk = normalizeProofBundleZkCurves({ + zkProof: bundle.zkProof, + zkMeta: bundle.zkMeta, + proofHints: bundle.proofHints, + zkScheme: bundle.zkScheme, + }); + + const zkProof = normalizedZk.zkProof ?? bundle.zkProof; + const zkMeta = normalizedZk.zkMeta ?? bundle.zkMeta; + const zkPublicInputs = buildZkPublicInputs(bundle.zkPoseidonHash) ?? (bundle.zkPublicInputs as unknown); + + return dropUndefined({ + v: typeof bundle.v === "string" ? bundle.v : undefined, + hashAlg, + canon, + bindings, + zkStatement, + proofCapsule: bundle.proofCapsule, + capsuleHash: bundle.capsuleHash, + svgHash: bundle.svgHash, + zkPoseidonHash: bundle.zkPoseidonHash, + zkProof, + zkPublicInputs, + zkMeta, + }); +} + +export async function computeBundleHash(bundleRoot: BundleRoot): Promise { + return await sha256Hex(jcsCanonicalize(bundleRoot as JcsValue)); +} + +export function challengeFromBundleHash(bundleHash: string): string { + return base64UrlEncode(hexToBytes(bundleHash)); +} + +export function normalizeBundle(bundle: ProofBundleLike): NormalizedBundle { + const normalizedZk = normalizeProofBundleZkCurves({ + zkProof: bundle.zkProof, + zkMeta: bundle.zkMeta, + bundleRoot: isRecord(bundle.bundleRoot) ? (bundle.bundleRoot as BundleRoot) : undefined, + proofHints: bundle.proofHints, + zkScheme: bundle.zkScheme, + }); + + const bundleRootBase = normalizedZk.bundleRoot ?? (isRecord(bundle.bundleRoot) ? bundle.bundleRoot : undefined); + const normalizedZkProof = normalizedZk.zkProof ?? bundle.zkProof; + const normalizedZkMeta = normalizedZk.zkMeta ?? bundle.zkMeta; + + const bundleRoot = bundleRootBase + ? normalizeBundleRoot(bundleRootBase as Record) + : buildBundleRoot({ + ...bundle, + zkProof: normalizedZkProof, + zkMeta: normalizedZkMeta, + }); + + const bindings = bundleRoot.bindings ?? bundle.bindings ?? PROOF_BINDINGS; + const zkStatement = bundleRoot.zkStatement ?? bundle.zkStatement; + + const transportBase: Record = isRecord(bundle.transport) ? (bundle.transport as Record) : {}; + const transport = dropUndefined({ + shareUrl: + typeof bundle.shareUrl === "string" + ? bundle.shareUrl + : typeof transportBase.shareUrl === "string" + ? (transportBase.shareUrl as string) + : undefined, + verifierUrl: + typeof bundle.verifierUrl === "string" + ? bundle.verifierUrl + : typeof transportBase.verifierUrl === "string" + ? (transportBase.verifierUrl as string) + : undefined, + verifiedAtPulse: + typeof bundle.verifiedAtPulse === "number" && Number.isFinite(bundle.verifiedAtPulse) + ? bundle.verifiedAtPulse + : typeof transportBase.verifiedAtPulse === "number" && Number.isFinite(transportBase.verifiedAtPulse as number) + ? (transportBase.verifiedAtPulse as number) + : undefined, + verifier: bundle.verifier ?? (transportBase.verifier as VerificationSource | undefined), + proofHints: "proofHints" in bundle ? bundle.proofHints : transportBase.proofHints, + }); + + // ✅ FIX: ensure verificationCacheBase is record-shaped (not {}), so property reads are type-safe. + const verificationCacheBase: VerificationCacheRecord = isRecord(bundle.verificationCache) + ? (bundle.verificationCache as VerificationCacheRecord) + : ({} as VerificationCacheRecord); + + const zkVerifiedCached = + typeof bundle.zkVerified === "boolean" + ? bundle.zkVerified + : typeof verificationCacheBase.zkVerifiedCached === "boolean" + ? verificationCacheBase.zkVerifiedCached + : undefined; + + const verificationCacheObj = dropUndefined({ + v: verificationCacheBase.v === "KVC-1" ? verificationCacheBase.v : undefined, + cacheKey: typeof verificationCacheBase.cacheKey === "string" ? verificationCacheBase.cacheKey : undefined, + bundleHash: typeof verificationCacheBase.bundleHash === "string" ? verificationCacheBase.bundleHash : undefined, + zkPoseidonHash: typeof verificationCacheBase.zkPoseidonHash === "string" ? verificationCacheBase.zkPoseidonHash : undefined, + verificationVersion: + typeof verificationCacheBase.verificationVersion === "string" ? verificationCacheBase.verificationVersion : undefined, + verifiedAtPulse: + typeof verificationCacheBase.verifiedAtPulse === "number" && Number.isFinite(verificationCacheBase.verifiedAtPulse) + ? verificationCacheBase.verifiedAtPulse + : undefined, + verifier: typeof verificationCacheBase.verifier === "string" ? verificationCacheBase.verifier : undefined, + createdAtMs: + typeof verificationCacheBase.createdAtMs === "number" && Number.isFinite(verificationCacheBase.createdAtMs) + ? verificationCacheBase.createdAtMs + : undefined, + expiresAtPulse: + typeof verificationCacheBase.expiresAtPulse === "number" && Number.isFinite(verificationCacheBase.expiresAtPulse) + ? verificationCacheBase.expiresAtPulse + : verificationCacheBase.expiresAtPulse === null + ? null + : undefined, + zkVerifiedCached, + }); + + const verificationCache = + Object.keys(verificationCacheObj).length > 0 ? (verificationCacheObj as unknown as VerificationCache) : undefined; + + return { + mode: bundle.mode === "receive" || bundle.mode === "origin" ? bundle.mode : undefined, + originBundleHash: typeof bundle.originBundleHash === "string" ? bundle.originBundleHash : undefined, + receiveBundleHash: typeof bundle.receiveBundleHash === "string" ? bundle.receiveBundleHash : undefined, + originAuthorSig: bundle.originAuthorSig ?? null, + receiveSig: bundle.receiveSig ?? null, + receivePulse: + typeof bundle.receivePulse === "number" && Number.isFinite(bundle.receivePulse) ? bundle.receivePulse : undefined, + ownerPhiKey: typeof bundle.ownerPhiKey === "string" ? bundle.ownerPhiKey : undefined, + ownerKeyDerivation: bundle.ownerKeyDerivation, + proofCapsule: bundleRoot.proofCapsule ?? bundle.proofCapsule, + capsuleHash: bundleRoot.capsuleHash ?? bundle.capsuleHash, + svgHash: bundleRoot.svgHash ?? bundle.svgHash, + bundleRoot, + bundleHash: typeof bundle.bundleHash === "string" ? bundle.bundleHash : undefined, + authorSig: bundle.authorSig ?? null, + zkProof: bundleRoot.zkProof ?? normalizedZkProof, + zkPublicInputs: bundleRoot.zkPublicInputs ?? bundle.zkPublicInputs, + bindings, + zkStatement, + zkMeta: bundleRoot.zkMeta ?? normalizedZkMeta, + verificationCache, + transport: Object.keys(transport).length ? (transport as ProofBundleTransport) : undefined, + zkPoseidonHash: bundleRoot.zkPoseidonHash ?? bundle.zkPoseidonHash, + cacheKey: typeof bundle.cacheKey === "string" ? bundle.cacheKey : undefined, + receipt: bundle.receipt, + receiptHash: typeof bundle.receiptHash === "string" ? bundle.receiptHash : undefined, + verificationSig: bundle.verificationSig, + }; +} + +function hasProofPoints(proof: unknown): boolean { + if (!isRecord(proof)) return false; + return ["pi_a", "pi_b", "pi_c"].some((key) => proof[key] != null); +} + +export function assertZkCurveConsistency(params: { zkProof?: unknown; zkMeta?: ZkMeta }): void { + const proofRecord = isRecord(params.zkProof) ? params.zkProof : undefined; + const proofCurve = normalizeZkCurve(proofRecord?.curve); + const metaCurve = normalizeZkCurve(params.zkMeta?.curve); + const proofPoints = hasProofPoints(params.zkProof); + + const protocol = + typeof proofRecord?.protocol === "string" + ? (proofRecord.protocol as string) + : typeof params.zkMeta?.protocol === "string" + ? params.zkMeta.protocol + : proofPoints + ? "groth16" + : undefined; + const scheme = typeof params.zkMeta?.scheme === "string" ? params.zkMeta.scheme : undefined; + const circuitId = typeof params.zkMeta?.circuitId === "string" ? params.zkMeta.circuitId : undefined; + + const inferredCurve = !proofCurve && !metaCurve ? inferZkCurveFromContext({ protocol, scheme, circuitId }) : undefined; + + if (proofCurve && metaCurve && proofCurve !== metaCurve) { + throw new Error("zk curve mismatch (meta vs proof)"); + } + + // Legacy/omitted curve metadata is acceptable; only fail on explicit mismatch. + // If both are missing but we can infer and proof points exist, allow. + if (!proofCurve && !metaCurve && inferredCurve && proofPoints) { + return; + } +} + +export function assertZkPublicInputsContract(params: { zkPublicInputs?: unknown; zkPoseidonHash?: string }): void { + if (params.zkPublicInputs == null && params.zkPoseidonHash == null) return; + if (typeof params.zkPoseidonHash !== "string" || !params.zkPoseidonHash.trim()) { + throw new Error("zk public inputs contract violated"); + } + if (!Array.isArray(params.zkPublicInputs)) { + throw new Error("zk public inputs contract violated"); + } + const inputs = params.zkPublicInputs.map((entry) => String(entry)); + if (inputs.length !== 2) { + throw new Error("zk public inputs contract violated"); + } + if (inputs[0] !== inputs[1]) { + throw new Error("zk public inputs contract violated"); + } + if (String(inputs[0]) !== String(params.zkPoseidonHash)) { + throw new Error("zk public inputs contract violated"); + } +} + /** Convenience short display for hashes. */ export function shortHash10(h: string): string { const s = typeof h === "string" ? h.trim() : ""; diff --git a/src/components/SealMomentModalTransfer.tsx b/src/components/SealMomentModalTransfer.tsx index ebb819f3c..124468995 100644 --- a/src/components/SealMomentModalTransfer.tsx +++ b/src/components/SealMomentModalTransfer.tsx @@ -33,6 +33,7 @@ interface Props { /** Full child-transfer URL (includes amount & nonce in its payload). */ url: string; hash: string; + shareText?: string; onClose: () => void; /** Preserved for backward compat; unused now. */ onDownloadZip: () => @@ -70,7 +71,7 @@ function registerLocally(url: string) { } const SealMomentModal: FC = (props) => { - const { open, url, hash, onClose } = props; // keep props shape; don't use onDownloadZip + const { open, url, hash, shareText, onClose } = props; // keep props shape; don't use onDownloadZip /* refs & state (Hooks must be unconditionally called) */ const dlgRef = useRef(null); @@ -211,14 +212,17 @@ const SealMomentModal: FC = (props) => { try { if (canShare && typeof navigator !== "undefined") { const nav = navigator as Navigator & { share?: (data: ShareData) => Promise }; + const shareMessage = shareText?.trim() || "Sealed Kairos Moment"; await nav.share?.({ title: "Kairos Sigil-Glyph", - text: "Sealed Kairos Moment", + text: shareMessage, url, }); announce("Share sheet opened"); } else { - await copy(url, "Link"); + const shareMessage = shareText?.trim(); + const copyPayload = shareMessage ? `${shareMessage}\n${url}` : url; + await copy(copyPayload, "Link"); } } catch { /* user canceled; ignore */ diff --git a/src/components/SigilModal.tsx b/src/components/SigilModal.tsx index 2c09fe531..4ac065a30 100644 --- a/src/components/SigilModal.tsx +++ b/src/components/SigilModal.tsx @@ -42,15 +42,25 @@ import { buildProofHints, generateZkProofFromPoseidonHash } from "../utils/zkPro import { computeZkPoseidonHash } from "../utils/kai"; import JSZip from "jszip"; import { - buildBundleUnsigned, + buildBundleRoot, + buildZkPublicInputs, buildVerifierUrl, - hashBundle, + computeBundleHash, hashProofCapsuleV1, hashSvgText, + normalizeProofBundleZkCurves, normalizeChakraDay, PROOF_CANON, + PROOF_BINDINGS, PROOF_HASH_ALG, + ZK_PUBLIC_INPUTS_CONTRACT, + ZK_STATEMENT_BINDING, + ZK_STATEMENT_ENCODING, + ZK_STATEMENT_DOMAIN, + type BundleRoot, + type ProofBundleTransport, type ProofCapsuleV1, + type ZkMeta, } from "./KaiVoh/verifierProof"; import type { AuthorSig } from "../utils/authorSig"; import { ensurePasskey, signBundleHash } from "../utils/webauthnKAS"; @@ -1113,17 +1123,27 @@ const SigilModal: FC = ({ onClose }: Props) => { type ProofBundle = { hashAlg: "sha256"; canon: "JCS"; + bindings: typeof PROOF_BINDINGS; + zkStatement?: { + publicInputOf: typeof ZK_STATEMENT_BINDING; + domainTag: string; + publicInputsContract?: typeof ZK_PUBLIC_INPUTS_CONTRACT; + encoding?: typeof ZK_STATEMENT_ENCODING; + }; + bundleRoot?: BundleRoot; + transport?: ProofBundleTransport; proofCapsule: ProofCapsuleV1; capsuleHash: string; svgHash: string; bundleHash: string; - shareUrl: string; - verifierUrl: string; + shareUrl?: string; + verifierUrl?: string; authorSig: AuthorSig | null; zkPoseidonHash?: string; zkProof?: unknown; proofHints?: unknown; zkPublicInputs?: unknown; + zkMeta?: ZkMeta; }; const makeSharePayload = ( @@ -1269,7 +1289,7 @@ const SigilModal: FC = ({ onClose }: Props) => { typeof payloadFromUrl?.userPhiKey === "string" ? payloadFromUrl.userPhiKey : phiKeyAttr; - const payloadHashHex = sealHash || payloadHashAttr; + const payloadHashHex = payloadHashAttr || sealHash; if (!kaiSignature) return "Export failed: kaiSignature missing from SVG."; if (!phiKey) return "Export failed: Φ-Key missing from SVG."; @@ -1402,8 +1422,9 @@ const SigilModal: FC = ({ onClose }: Props) => { throw new Error("ZK proof missing"); } } - if (zkPublicInputs) { - svgClone.setAttribute("data-zk-public-inputs", JSON.stringify(zkPublicInputs)); + const normalizedZkPublicInputs = zkPoseidonHash ? buildZkPublicInputs(zkPoseidonHash) : zkPublicInputs; + if (normalizedZkPublicInputs) { + svgClone.setAttribute("data-zk-public-inputs", JSON.stringify(normalizedZkPublicInputs)); } if (zkPoseidonHash) { svgClone.setAttribute("data-zk-scheme", "groth16-poseidon"); @@ -1421,22 +1442,44 @@ const SigilModal: FC = ({ onClose }: Props) => { } const svgHash = await hashSvgText(svgString); + const zkStatement = zkPoseidonHash + ? { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT, + encoding: ZK_STATEMENT_ENCODING, + } + : undefined; + const zkMeta = zkPoseidonHash + ? { + protocol: "groth16", + scheme: "groth16-poseidon", + circuitId: "sigil_proof", + } + : undefined; + const normalizedZk = normalizeProofBundleZkCurves({ zkProof, zkMeta, proofHints }); + zkProof = normalizedZk.zkProof; + const zkMetaNormalized = normalizedZk.zkMeta; const proofBundleBase = { hashAlg: PROOF_HASH_ALG, canon: PROOF_CANON, + bindings: PROOF_BINDINGS, + zkStatement, proofCapsule, capsuleHash, svgHash, - shareUrl, - verifierUrl, - authorSig: null as AuthorSig | null, zkPoseidonHash, zkProof, + zkPublicInputs: normalizedZkPublicInputs, + zkMeta: zkMetaNormalized, + }; + const transport = { + shareUrl, + verifierUrl, proofHints, - zkPublicInputs, }; - const bundleUnsigned = buildBundleUnsigned(proofBundleBase); - const computedBundleHash = await hashBundle(bundleUnsigned); + const bundleRoot = buildBundleRoot(proofBundleBase); + const computedBundleHash = await computeBundleHash(bundleRoot); let authorSig: AuthorSig | null = null; try { await ensurePasskey(phiKey); @@ -1449,8 +1492,11 @@ const SigilModal: FC = ({ onClose }: Props) => { } const proofBundle: ProofBundle = { ...proofBundleBase, + bundleRoot, bundleHash: computedBundleHash, - authorSig, + authorSig, + transport, + proofHints, }; if (authorSig?.v === "KAS-1") { const authUrl = shareUrl || verifierUrl; diff --git a/src/components/VerifierStamper/VerifierStamper.tsx b/src/components/VerifierStamper/VerifierStamper.tsx index 8b2ade62a..715e17d7c 100644 --- a/src/components/VerifierStamper/VerifierStamper.tsx +++ b/src/components/VerifierStamper/VerifierStamper.tsx @@ -101,13 +101,33 @@ import { DEFAULT_ISSUANCE_POLICY, quotePhiForUsd } from "../../utils/phi-issuanc import { BREATH_MS } from "../valuation/constants"; import { recordSend, getSpentScaledFor, markConfirmedByLeaf } from "../../utils/sendLedger"; import { recordSigilTransferMovement } from "../../utils/sigilTransferRegistry"; -import { buildBundleUnsigned, buildVerifierSlug, hashBundle, hashProofCapsuleV1, hashSvgText, normalizeChakraDay, PROOF_CANON, PROOF_HASH_ALG } from "../KaiVoh/verifierProof"; +import { + buildBundleRoot, + buildBundleUnsigned, + buildVerifierSlug, + computeBundleHash, + hashBundle, + hashProofCapsuleV1, + hashSvgText, + normalizeChakraDay, + PROOF_CANON, + PROOF_HASH_ALG, + PROOF_BINDINGS, + type ProofBundleLike, +} from "../KaiVoh/verifierProof"; import { isKASAuthorSig } from "../../utils/authorSig"; import { computeZkPoseidonHash } from "../../utils/kai"; import { generateZkProofFromPoseidonHash } from "../../utils/zkProof"; import type { SigilProofHints } from "../../types/sigil"; import type { SigilSharePayloadLoose } from "../SigilExplorer/types"; -import { apiFetchWithFailover, API_URLS_PATH, loadApiBackupDeadUntil, loadApiBaseHint } from "../SigilExplorer/apiClient"; +import { buildOwnerKeyDerivation, deriveOwnerPhiKeyFromReceive } from "../../utils/ownerPhiKey"; +import { buildReceiveBundleRoot, hashReceiveBundleRoot } from "../../utils/receiveBundle"; +import { + apiFetchWithFailover, + loadApiBackupDeadUntil, + loadApiBaseHint, + urlUrls, +} from "../SigilExplorer/apiClient"; import { extractPayloadFromUrl } from "../SigilExplorer/url"; import { enqueueInhaleKrystal, flushInhaleQueue } from "../SigilExplorer/inhaleQueue"; import { memoryRegistry, isOnline } from "../SigilExplorer/registryStore"; @@ -134,6 +154,13 @@ type GlyphUnlockState = { unlockedAtNonce?: string; }; +function shortenPhiKey(phiKey: string): string { + const trimmed = String(phiKey || "").trim(); + if (!trimmed) return ""; + if (trimmed.length <= 24) return trimmed; + return `${trimmed.slice(0, 12)}…${trimmed.slice(-10)}`; +} + function readPhiAmountFromMeta(meta: SigilMetadataWithOptionals): string | undefined { const candidate = meta.childAllocationPhi ?? @@ -170,6 +197,14 @@ function readReceiveSigFromBundle(raw: unknown): ReceiveSig | null { return isReceiveSig(candidate) ? candidate : null; } +function readReceiveBundleHashFromBundle(raw: unknown): string | null { + if (!isRecord(raw)) return null; + const candidate = raw.receiveBundleHash; + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + return trimmed ? trimmed : null; +} + const RECEIVE_LOCK_PREFIX = "kai:receive:lock:v1"; const RECEIVE_REMOTE_LIMIT = 200; const RECEIVE_REMOTE_PAGES = 3; @@ -429,11 +464,16 @@ const VerifierStamperInner: React.FC = () => { bundleSeed = { hashAlg: proofMetaValue?.hashAlg ?? PROOF_HASH_ALG, canon: proofMetaValue?.canon ?? PROOF_CANON, + bindings: proofMetaValue?.bindings, + zkStatement: proofMetaValue?.zkStatement, + bundleRoot: proofMetaValue?.bundleRoot, proofCapsule: fallbackCapsule, capsuleHash: capsuleHashNext, svgHash, shareUrl: proofMetaValue?.shareUrl, verifierUrl: proofMetaValue?.verifierUrl, + verifier: proofMetaValue?.verifier, + verifiedAtPulse: proofMetaValue?.verifiedAtPulse, zkPoseidonHash: proofMetaValue?.zkPoseidonHash, zkProof: proofMetaValue?.zkProof, proofHints: proofMetaValue?.proofHints, @@ -443,8 +483,25 @@ const VerifierStamperInner: React.FC = () => { } if (!bundleSeed) return null; - const bundleUnsigned = buildBundleUnsigned(bundleSeed); - return hashBundle(bundleUnsigned); + if (proofMetaValue?.bundleRoot) { + return computeBundleHash(proofMetaValue.bundleRoot); + } + const bundleRoot = buildBundleRoot(bundleSeed); + const rootHash = await computeBundleHash(bundleRoot); + const legacySeed = { ...bundleSeed } as Record; + delete legacySeed.bundleRoot; + delete legacySeed.transport; + delete legacySeed.verificationCache; + delete legacySeed.cacheKey; + delete legacySeed.receipt; + delete legacySeed.receiptHash; + delete legacySeed.verificationSig; + delete legacySeed.zkMeta; + const legacyUnsigned = buildBundleUnsigned(legacySeed); + const legacyHash = await hashBundle(legacyUnsigned); + if (proofMetaValue?.bindings?.bundleHashOf === PROOF_BINDINGS.bundleHashOf) return rootHash; + if (proofMetaValue?.bindings?.bundleHashOf === "JCS(bundleWithoutBundleHash)") return legacyHash; + return legacyHash; }, [] ); @@ -483,18 +540,23 @@ const VerifierStamperInner: React.FC = () => { [unlockBusy, unlockState.isUnlocked, bundleHash, proofBundleMeta?.authorSig] ); - const claimReceiveSig = useCallback(async (): Promise => { - if (receiveBusy || receiveStatus !== "new") return null; - if (!bundleHash) return null; - setReceiveBusy(true); - try { - const passkey = await resolveReceiverPasskey(); - const { nonce, challengeBytes } = await buildKasChallenge("receive", bundleHash); - const assertion = await getWebAuthnAssertionJson({ - challenge: challengeBytes, - allowCredIds: [passkey.credId], - preferInternal: true, - }); + const claimReceiveSig = useCallback( + async ( + receiveBundleHash: string, + receivePulse: number, + options?: { force?: boolean } + ): Promise => { + if (receiveBusy || (!options?.force && receiveStatus !== "new")) return null; + if (!receiveBundleHash) return null; + setReceiveBusy(true); + try { + const passkey = await resolveReceiverPasskey(); + const { nonce, challengeBytes } = await buildKasChallenge("receive", receiveBundleHash); + const assertion = await getWebAuthnAssertionJson({ + challenge: challengeBytes, + allowCredIds: [passkey.credId], + preferInternal: true, + }); const ok = await verifyWebAuthnAssertion({ assertion, expectedChallenge: challengeBytes, @@ -506,27 +568,30 @@ const VerifierStamperInner: React.FC = () => { return null; } - const nextSig: ReceiveSig = { - v: "KRS-1", - alg: "webauthn-es256", - nonce, - binds: { bundleHash }, - credId: passkey.credId, - pubKeyJwk: passkey.pubKeyJwk as ReceiveSig["pubKeyJwk"], - assertion, - }; + const nextSig: ReceiveSig = { + v: "KRS-1", + alg: "webauthn-es256", + nonce, + binds: { bundleHash: receiveBundleHash }, + createdAtPulse: receivePulse, + credId: passkey.credId, + pubKeyJwk: passkey.pubKeyJwk as ReceiveSig["pubKeyJwk"], + assertion, + }; - window.localStorage.setItem(`received:${bundleHash}`, JSON.stringify(nextSig)); - setReceiveSig(nextSig); - setReceiveStatus("already"); - return nextSig; - } catch { - setError("Receive claim canceled."); - return null; - } finally { - setReceiveBusy(false); - } - }, [receiveBusy, receiveStatus, bundleHash, resolveReceiverPasskey]); + window.localStorage.setItem(`received:${receiveBundleHash}`, JSON.stringify(nextSig)); + setReceiveSig(nextSig); + setReceiveStatus("already"); + return nextSig; + } catch { + setError("Receive claim canceled."); + return null; + } finally { + setReceiveBusy(false); + } + }, + [receiveBusy, receiveStatus, resolveReceiverPasskey], + ); const [me, setMe] = useState(null); useEffect(() => { @@ -733,9 +798,13 @@ const VerifierStamperInner: React.FC = () => { ); const buildReceiveLockKeys = useCallback( - async (m: SigilMetadata): Promise<{ keys: string[]; canonical: string | null; nonce: string | null }> => { + async ( + m: SigilMetadata, + options?: { bundleHash?: string | null; canonicalOverride?: string | null } + ): Promise<{ keys: string[]; canonical: string | null; nonce: string | null }> => { const keys = new Set(); - if (bundleHash) keys.add(`${RECEIVE_LOCK_PREFIX}:bundle:${bundleHash}`); + const bundleHashValue = options?.bundleHash ?? bundleHash; + if (bundleHashValue) keys.add(`${RECEIVE_LOCK_PREFIX}:bundle:${bundleHashValue}`); const last = m.transfers?.slice(-1)[0]; if (last) { @@ -749,7 +818,7 @@ const VerifierStamperInner: React.FC = () => { null; if (nonce) keys.add(`${RECEIVE_LOCK_PREFIX}:nonce:${nonce}`); - let effCanonical = canonical; + let effCanonical = options?.canonicalOverride ?? canonical; if (!effCanonical) { try { const eff = await computeEffectiveCanonical(m); @@ -809,10 +878,10 @@ const VerifierStamperInner: React.FC = () => { const offset = page * RECEIVE_REMOTE_LIMIT; const res = await apiFetchWithFailover( (base) => { - const url = new URL(API_URLS_PATH, base); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("limit", String(RECEIVE_REMOTE_LIMIT)); - return url.toString(); + const params = new URLSearchParams(); + params.set("offset", String(offset)); + params.set("limit", String(RECEIVE_REMOTE_LIMIT)); + return `${urlUrls(base)}?${params.toString()}`; }, { method: "GET", cache: "no-store" } ); @@ -880,17 +949,22 @@ const VerifierStamperInner: React.FC = () => { ); const hasReceiveLock = useCallback( - async (m: SigilMetadata): Promise => { + async (m: SigilMetadata, options?: { includeRemote?: boolean }): Promise => { if (await hasLocalReceiveLock(m)) return true; if (await hasRegistryReceiveLock(m)) return true; + if (options?.includeRemote === false) return false; return hasRemoteReceiveLock(m); }, [hasLocalReceiveLock, hasRegistryReceiveLock, hasRemoteReceiveLock] ); const writeReceiveLock = useCallback( - async (m: SigilMetadata, nowPulse: number) => { - const { keys } = await buildReceiveLockKeys(m); + async ( + m: SigilMetadata, + nowPulse: number, + options?: { bundleHash?: string | null; canonicalOverride?: string | null } + ) => { + const { keys } = await buildReceiveLockKeys(m, options); for (const key of keys) { if (!window.localStorage.getItem(key)) { window.localStorage.setItem(key, JSON.stringify({ pulse: nowPulse })); @@ -900,6 +974,184 @@ const VerifierStamperInner: React.FC = () => { [buildReceiveLockKeys] ); + const rebuildProofBundleForSegment = useCallback( + async ( + svgText: string, + metaValue: SigilMetadata + ): Promise<{ bundle: Record; bundleHash: string | null; receiveBundleHash: string | null } | null> => { + const proofCapsule = proofBundleMeta?.proofCapsule; + const capsuleHash = proofBundleMeta?.capsuleHash ?? (proofCapsule ? await hashProofCapsuleV1(proofCapsule) : null); + const svgHash = await hashSvgText(svgText); + const rawBundle = proofBundleMeta?.raw; + + let nextBundle: Record | null = null; + if (rawBundle && isRecord(rawBundle)) { + nextBundle = { ...(rawBundle as Record), svgHash, capsuleHash, proofCapsule: proofCapsule ?? undefined }; + } else if (metaValue.kaiSignature && typeof metaValue.pulse === "number") { + const chakraDay = normalizeChakraDay(metaValue.chakraDay ?? "") ?? "Crown"; + const verifierSlug = buildVerifierSlug(metaValue.pulse, metaValue.kaiSignature); + const phiKey = metaValue.userPhiKey ?? (await derivePhiKeyFromSig(metaValue.kaiSignature)); + const fallbackCapsule = { + v: "KPV-1" as const, + pulse: metaValue.pulse, + chakraDay, + kaiSignature: metaValue.kaiSignature, + phiKey, + verifierSlug, + }; + const capsuleHashNext = capsuleHash ?? (await hashProofCapsuleV1(fallbackCapsule)); + nextBundle = { + hashAlg: proofBundleMeta?.hashAlg ?? PROOF_HASH_ALG, + canon: proofBundleMeta?.canon ?? PROOF_CANON, + bindings: proofBundleMeta?.bindings, + zkStatement: proofBundleMeta?.zkStatement, + bundleRoot: proofBundleMeta?.bundleRoot, + proofCapsule: fallbackCapsule, + capsuleHash: capsuleHashNext, + svgHash, + shareUrl: proofBundleMeta?.shareUrl, + verifierUrl: proofBundleMeta?.verifierUrl, + verifier: proofBundleMeta?.verifier, + verifiedAtPulse: proofBundleMeta?.verifiedAtPulse, + zkPoseidonHash: proofBundleMeta?.zkPoseidonHash, + zkProof: proofBundleMeta?.zkProof, + proofHints: proofBundleMeta?.proofHints, + zkPublicInputs: proofBundleMeta?.zkPublicInputs, + authorSig: proofBundleMeta?.authorSig ?? null, + mode: proofBundleMeta?.mode, + originBundleHash: proofBundleMeta?.originBundleHash, + originAuthorSig: proofBundleMeta?.originAuthorSig ?? null, + receiveSig: proofBundleMeta?.receiveSig ?? null, + receivePulse: proofBundleMeta?.receivePulse, + receiveBundleHash: proofBundleMeta?.receiveBundleHash, + ownerPhiKey: proofBundleMeta?.ownerPhiKey, + ownerKeyDerivation: proofBundleMeta?.ownerKeyDerivation, + }; + } + + if (!nextBundle) return null; + + const bundleRoot = buildBundleRoot(nextBundle); + const rootHash = await computeBundleHash(bundleRoot); + const legacySeed = { ...nextBundle } as Record; + delete legacySeed.bundleRoot; + delete legacySeed.transport; + delete legacySeed.verificationCache; + delete legacySeed.cacheKey; + delete legacySeed.receipt; + delete legacySeed.receiptHash; + delete legacySeed.verificationSig; + delete legacySeed.zkMeta; + const legacyUnsigned = buildBundleUnsigned(legacySeed); + const legacyHash = await hashBundle(legacyUnsigned); + const useRootHash = + proofBundleMeta?.bundleRoot !== undefined || + proofBundleMeta?.bindings?.bundleHashOf === PROOF_BINDINGS.bundleHashOf || + (isRecord(rawBundle) && "bundleRoot" in rawBundle); + const bundleHashNext = useRootHash ? rootHash : legacyHash; + if (useRootHash) { + nextBundle.bundleRoot = bundleRoot; + } else { + delete nextBundle.bundleRoot; + } + nextBundle.bundleHash = bundleHashNext; + + const priorReceiveSig = readReceiveSigFromBundle(rawBundle ?? nextBundle); + const receiveMode = nextBundle.mode === "receive" || Boolean(priorReceiveSig) || Boolean(nextBundle.receiveBundleHash); + let receiveBundleHashNext: string | null = null; + if (receiveMode) { + let receivePulse = typeof nextBundle.receivePulse === "number" ? nextBundle.receivePulse : priorReceiveSig?.createdAtPulse; + if (typeof receivePulse !== "number" || !Number.isFinite(receivePulse)) receivePulse = kaiPulseNow(); + + const originBundleHash = typeof nextBundle.originBundleHash === "string" ? nextBundle.originBundleHash : undefined; + const originAuthorSig = isKASAuthorSig(nextBundle.originAuthorSig) ? nextBundle.originAuthorSig : null; + + const receiveBundleRoot = buildReceiveBundleRoot({ + bundleRoot, + bundle: nextBundle as ProofBundleLike, + originBundleHash, + originAuthorSig, + receivePulse, + }); + receiveBundleHashNext = await hashReceiveBundleRoot(receiveBundleRoot); + + let receiveSigNext = priorReceiveSig; + if (receiveSigNext && receiveSigNext.binds.bundleHash !== receiveBundleHashNext) { + receiveSigNext = await claimReceiveSig(receiveBundleHashNext, receivePulse, { force: true }); + } + + if (receiveSigNext) { + nextBundle.receiveSig = receiveSigNext; + nextBundle.receivePulse = receiveSigNext.createdAtPulse ?? receivePulse; + nextBundle.receiveBundleHash = receiveBundleHashNext; + nextBundle.receiveBundleRoot = receiveBundleRoot; + const ownerPhiKey = await deriveOwnerPhiKeyFromReceive({ + receiverPubKeyJwk: receiveSigNext.pubKeyJwk, + receivePulse: nextBundle.receivePulse as number, + receiveBundleHash: receiveBundleHashNext, + }); + nextBundle.ownerPhiKey = ownerPhiKey; + nextBundle.ownerKeyDerivation = buildOwnerKeyDerivation({ + originPhiKey: proofCapsule?.phiKey, + receivePulse: nextBundle.receivePulse as number, + receiveBundleHash: receiveBundleHashNext, + }); + } else { + delete nextBundle.receiveSig; + delete nextBundle.receiveBundleHash; + delete nextBundle.receiveBundleRoot; + delete nextBundle.ownerPhiKey; + delete nextBundle.ownerKeyDerivation; + } + + if (rawBundle && isRecord(rawBundle) && priorReceiveSig && receiveSigNext?.binds.bundleHash !== priorReceiveSig.binds.bundleHash) { + const receiveSigHistory = collectReceiveSigHistory(rawBundle, priorReceiveSig); + if (receiveSigHistory.length > 0) nextBundle.receiveSigHistory = receiveSigHistory; + } + } + + return { bundle: nextBundle, bundleHash: bundleHashNext, receiveBundleHash: receiveBundleHashNext }; + }, + [claimReceiveSig, proofBundleMeta] + ); + + const buildSegmentedSvgDataUrl = useCallback( + async ( + m: SigilMetadata + ): Promise<{ + dataUrl: string; + svgText: string; + bundleHash: string | null; + receiveBundleHash: string | null; + proofBundle?: Record | null; + } | null> => { + if (!svgURL) return null; + const rawSvg = await fetch(svgURL).then((r) => r.text()); + let svgText = embedMetadataText(rawSvg, m); + let bundleHashNext: string | null = null; + let receiveBundleHashNext: string | null = null; + let proofBundle: Record | null = null; + if (proofBundleMeta) { + const rebuilt = await rebuildProofBundleForSegment(svgText, m); + if (rebuilt) { + svgText = embedProofMetadata(svgText, rebuilt.bundle); + bundleHashNext = rebuilt.bundleHash; + receiveBundleHashNext = rebuilt.receiveBundleHash; + proofBundle = rebuilt.bundle; + } + } + const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`; + return { + dataUrl, + svgText, + bundleHash: bundleHashNext, + receiveBundleHash: receiveBundleHashNext, + proofBundle, + }; + }, + [proofBundleMeta, rebuildProofBundleForSegment, svgURL] + ); + const publishReceiveLock = useCallback( async (m: SigilMetadata, amountPhi?: string) => { let canonicalHash = (m.canonicalHash as string | undefined)?.toLowerCase() ?? null; @@ -1131,6 +1383,10 @@ const VerifierStamperInner: React.FC = () => { if (receiveFromBundle) { metaNext = { ...metaNext, receiveSig: receiveFromBundle }; } + const receiveBundleHashFromBundle = readReceiveBundleHashFromBundle(proofMetaNext?.raw); + if (receiveBundleHashFromBundle && !metaNext.receiveBundleHash) { + metaNext = { ...metaNext, receiveBundleHash: receiveBundleHashFromBundle }; + } if (proofMetaNext?.raw && isRecord(proofMetaNext.raw)) { metaNext = { ...metaNext, proofBundleRaw: proofMetaNext.raw }; } @@ -1194,39 +1450,6 @@ const VerifierStamperInner: React.FC = () => { autoReceiveRef.current = null; } - if (bundleHash) { - const key = `received:${bundleHash}`; - const stored = window.localStorage.getItem(key); - if (stored) { - try { - const parsed = JSON.parse(stored) as unknown; - if (!alive) return; - setReceiveSig(isReceiveSig(parsed) ? parsed : null); - } catch { - if (!alive) return; - setReceiveSig(null); - } - if (!alive) return; - setReceiveStatus("already"); - return; - } - } - - const embedded = readReceiveSigFromBundle(proofBundleMeta?.raw); - if (embedded) { - if (!alive) return; - setReceiveSig(embedded); - setReceiveStatus("already"); - return; - } - - if (meta && (await hasReceiveLock(meta))) { - if (!alive) return; - setReceiveSig(null); - setReceiveStatus("already"); - return; - } - if (!alive) return; setReceiveSig(null); setReceiveStatus(bundleHash ? "new" : "idle"); @@ -1234,7 +1457,7 @@ const VerifierStamperInner: React.FC = () => { return () => { alive = false; }; - }, [bundleHash, proofBundleMeta?.raw, meta, hasReceiveLock]); + }, [bundleHash]); useEffect(() => { if (!bundleHash || unlockState.isUnlocked || !unlockState.isRequired) return; @@ -1487,6 +1710,22 @@ const VerifierStamperInner: React.FC = () => { [liveSig, computeEffectiveCanonical, contentSigExpected, receiveSig] ); + const markSegmentedAsReceived = useCallback( + async ( + m: SigilMetadata, + nowPulse: number, + options?: { bundleHash?: string | null; canonicalOverride?: string | null } + ) => { + try { + await writeReceiveLock(m, nowPulse, options); + setReceiveStatus("already"); + } catch (err) { + logError("segment.writeReceiveLock", err); + } + }, + [writeReceiveLock] + ); + useEffect(() => { if (meta) { void syncMetaAndUi(meta); @@ -1870,27 +2109,89 @@ const VerifierStamperInner: React.FC = () => { [conv.phiStringToSend, remainingPhiScaled] ); + const buildBundleZip = useCallback( + async (options: { + svgText: string; + meta: SigilMetadata; + base: string; + context: "download" | "receive" | "segment"; + proofBundle?: Record | null; + segmentFile?: { name: string; blob: Blob } | null; + }): Promise => { + const { svgText, meta: metaValue, base, context, proofBundle, segmentFile } = options; + const svgBlob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" }); + let pngBlob: Blob | null = null; + try { + const svgDataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`; + pngBlob = await pngBlobFromSvgDataUrl(svgDataUrl, 1024); + } catch (err) { + logError("pngBlobFromSvgDataUrl", err); + } + + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + zip.file(`${base}.svg`, svgBlob); + if (pngBlob) zip.file(`${base}.png`, pngBlob); + zip.file(`${base}.metadata.json`, JSON.stringify(metaValue, null, 2)); + if (proofBundle) { + zip.file(`${base}.proof_bundle.json`, JSON.stringify(proofBundle, null, 2)); + } + if (segmentFile) { + zip.file(segmentFile.name, segmentFile.blob); + } + const manifest = { + bundleVersion: "verifier-stamper-v1", + context, + createdAt: new Date().toISOString(), + pulse: typeof metaValue.pulse === "number" ? metaValue.pulse : null, + kaiPulse: typeof metaValue.kaiPulse === "number" ? metaValue.kaiPulse : null, + bundleHash: proofBundle && typeof proofBundle.bundleHash === "string" ? proofBundle.bundleHash : null, + receiveBundleHash: + proofBundle && typeof (proofBundle as { receiveBundleHash?: string }).receiveBundleHash === "string" + ? (proofBundle as { receiveBundleHash?: string }).receiveBundleHash + : null, + files: { + svg: `${base}.svg`, + png: pngBlob ? `${base}.png` : null, + metadata: `${base}.metadata.json`, + proofBundle: proofBundle ? `${base}.proof_bundle.json` : null, + segment: segmentFile?.name ?? null, + }, + }; + zip.file(`${base}.manifest.json`, JSON.stringify(manifest, null, 2)); + + return zip.generateAsync({ + type: "blob", + mimeType: "application/zip", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + streamFiles: true, + }); + }, + [] + ); + const downloadZip = useCallback(async () => { if (!meta || !svgURL) return; const svgDataUrl = await embedMetadata(svgURL, meta); - const svgBlob = await fetch(svgDataUrl).then((r) => r.blob()); - let pngBlob: Blob | null = null; - try { - pngBlob = await pngBlobFromSvgDataUrl(svgDataUrl, 1024); - } catch (err) { - logError("pngBlobFromSvgDataUrl", err); + let svgText = await fetch(svgDataUrl).then((r) => r.text()); + const proofBundle = proofBundleMeta?.raw && isRecord(proofBundleMeta.raw) ? proofBundleMeta.raw : null; + if (proofBundle) { + svgText = embedProofMetadata(svgText, proofBundle); } - const { default: JSZip } = await import("jszip"); - const zip = new JSZip(); const sigilPulse = meta.pulse ?? 0; const last = meta.transfers?.slice(-1)[0]; const sendPulse = last?.senderKaiPulse ?? meta.kaiPulse ?? kaiPulseNow(); const base = pulseFilename("sigil_bundle", sigilPulse, sendPulse); - zip.file(`${base}.svg`, svgBlob); - if (pngBlob) zip.file(`${base}.png`, pngBlob); - const zipBlob = await zip.generateAsync({ type: "blob" }); + const zipBlob = await buildBundleZip({ + svgText, + meta, + base, + context: "download", + proofBundle, + }); download(zipBlob, `${base}.zip`); - }, [meta, svgURL]); + }, [buildBundleZip, meta, proofBundleMeta, svgURL]); const isSendFilename = useMemo(() => (sourceFilename || "").toLowerCase().includes("sigil_send"), [sourceFilename]); @@ -2136,8 +2437,28 @@ const VerifierStamperInner: React.FC = () => { delete nextBundle.receiveSig; delete nextBundle.bundleHash; - const bundleUnsigned = buildBundleUnsigned(nextBundle); - const bundleHashNext = await hashBundle(bundleUnsigned); + const bundleRoot = buildBundleRoot(nextBundle); + const rootHash = await computeBundleHash(bundleRoot); + const legacySeed = { ...nextBundle } as Record; + delete legacySeed.bundleRoot; + delete legacySeed.transport; + delete legacySeed.verificationCache; + delete legacySeed.cacheKey; + delete legacySeed.receipt; + delete legacySeed.receiptHash; + delete legacySeed.verificationSig; + delete legacySeed.zkMeta; + const legacyUnsigned = buildBundleUnsigned(legacySeed); + const legacyHash = await hashBundle(legacyUnsigned); + const useRootHash = + isRecord(rawBundle) && + (isRecord(rawBundle.bindings) ? rawBundle.bindings.bundleHashOf === PROOF_BINDINGS.bundleHashOf : false); + const bundleHashNext = useRootHash || isRecord(rawBundle) && "bundleRoot" in rawBundle ? rootHash : legacyHash; + if (useRootHash || (isRecord(rawBundle) && "bundleRoot" in rawBundle)) { + nextBundle.bundleRoot = bundleRoot; + } else { + delete nextBundle.bundleRoot; + } nextBundle.bundleHash = bundleHashNext; childSvgWithProof = embedProofMetadata(childSvgText, nextBundle); } @@ -2164,13 +2485,36 @@ const VerifierStamperInner: React.FC = () => { const cap = updated.segmentSize ?? SEGMENT_SIZE; if (windowSize >= cap) { const { meta: rolled, segmentFileBlob } = await sealCurrentWindowIntoSegment(updated); - if (segmentFileBlob) + const segmented = await buildSegmentedSvgDataUrl(rolled); + if (segmented) { + const segmentName = `sigil_segment_${rolled.pulse ?? 0}_${String((rolled.segments?.length ?? 1) - 1).padStart(6, "0")}.json`; + const base = pulseFilename("sigil_segment_bundle", rolled.pulse ?? 0, nowPulse); + const zipBlob = await buildBundleZip({ + svgText: segmented.svgText, + meta: rolled, + base, + context: "segment", + proofBundle: segmented.proofBundle ?? null, + segmentFile: segmentFileBlob ? { name: segmentName, blob: segmentFileBlob } : null, + }); + download(zipBlob, `${base}.zip`); + } else if (segmentFileBlob) { download( segmentFileBlob, `sigil_segment_${rolled.pulse ?? 0}_${String((rolled.segments?.length ?? 1) - 1).padStart(6, "0")}.json` ); - const durl2 = await embedMetadata(svgURL, rolled); - download(durl2, `${pulseFilename("sigil_head_after_seal", rolled.pulse ?? 0, nowPulse)}.svg`); + } + const hasReceiveProof = + Boolean((rolled as SigilMetadataWithOptionals).receiveSig) || + Boolean(proofBundleMeta?.receiveSig) || + (isRecord(proofBundleMeta?.raw) && proofBundleMeta?.raw?.mode === "receive"); + if (hasReceiveProof) { + const eff = await computeEffectiveCanonical(rolled); + await markSegmentedAsReceived(rolled, nowPulse, { + bundleHash: segmented?.bundleHash ?? bundleHash, + canonicalOverride: eff.canonical, + }); + } const rolled2 = await refreshHeadWindow(rolled); await syncMetaAndUi(rolled2); setError(null); @@ -2208,17 +2552,13 @@ const VerifierStamperInner: React.FC = () => { return; } - if ((await hasLocalReceiveLock(meta)) || (await hasRegistryReceiveLock(meta))) { + if (await hasReceiveLock(meta, { includeRemote: false })) { setError("This transfer has already been received."); setReceiveStatus("already"); return; } let receiveSigLocal = receiveSig ?? null; - if (receiveStatus === "new" && !receiveSigLocal) { - receiveSigLocal = await claimReceiveSig(); - if (!receiveSigLocal) return; - } const { used } = getChildLockInfo(meta, kaiPulseNow()); if (used) { @@ -2348,11 +2688,22 @@ const VerifierStamperInner: React.FC = () => { logError("receive.recordTransferMovement", err); } - let durl = await embedMetadata(svgURL, updated); + const durl = await embedMetadata(svgURL, updated); const baseSvg = await fetch(durl).then((r) => r.text()); const svgHash = await hashSvgText(baseSvg); const proofCapsule = proofBundleMeta?.proofCapsule; const capsuleHash = proofBundleMeta?.capsuleHash ?? (proofCapsule ? await hashProofCapsuleV1(proofCapsule) : null); + let receivePulse = receiveSigLocal?.createdAtPulse ?? nowPulse; + const originBundleHash = + proofBundleMeta?.originBundleHash ?? + proofBundleMeta?.bundleHash ?? + (proofBundleMeta?.raw && isRecord(proofBundleMeta.raw) && typeof proofBundleMeta.raw.bundleHash === "string" + ? (proofBundleMeta.raw.bundleHash as string) + : undefined); + const originSigCandidate = + proofBundleMeta?.originAuthorSig ?? + (proofBundleMeta?.authorSig && isKASAuthorSig(proofBundleMeta.authorSig) ? proofBundleMeta.authorSig : null); + const originAuthorSig = originSigCandidate && isKASAuthorSig(originSigCandidate) ? originSigCandidate : null; let nextBundle: Record; if (proofBundleMeta?.raw && isRecord(proofBundleMeta.raw)) { @@ -2361,7 +2712,11 @@ const VerifierStamperInner: React.FC = () => { svgHash, capsuleHash, proofCapsule: proofCapsule ?? undefined, - ...(receiveSigLocal ? { receiveSig: receiveSigLocal } : {}), + mode: "receive", + originBundleHash, + originAuthorSig, + receivePulse, + authorSig: null, }; } else if (updated.kaiSignature && typeof updated.pulse === "number") { const chakraDay = normalizeChakraDay(updated.chakraDay ?? "") ?? "Crown"; @@ -2379,30 +2734,122 @@ const VerifierStamperInner: React.FC = () => { nextBundle = { hashAlg: proofBundleMeta?.hashAlg ?? PROOF_HASH_ALG, canon: proofBundleMeta?.canon ?? PROOF_CANON, + bindings: proofBundleMeta?.bindings, + zkStatement: proofBundleMeta?.zkStatement, + bundleRoot: proofBundleMeta?.bundleRoot, proofCapsule: fallbackCapsule, capsuleHash: capsuleHashNext, svgHash, shareUrl: proofBundleMeta?.shareUrl, verifierUrl: proofBundleMeta?.verifierUrl, + verifier: proofBundleMeta?.verifier, + verifiedAtPulse: proofBundleMeta?.verifiedAtPulse, zkPoseidonHash: proofBundleMeta?.zkPoseidonHash, zkProof: proofBundleMeta?.zkProof, proofHints: proofBundleMeta?.proofHints, zkPublicInputs: proofBundleMeta?.zkPublicInputs, - authorSig: proofBundleMeta?.authorSig ?? null, - ...(receiveSigLocal ? { receiveSig: receiveSigLocal } : {}), + mode: "receive", + originBundleHash, + originAuthorSig, + receivePulse, + authorSig: null, + }; + } else { + nextBundle = { + svgHash, + capsuleHash, + mode: "receive", + originBundleHash, + originAuthorSig, + receivePulse, + authorSig: null, }; + } + + const rawBundle = proofBundleMeta?.raw; + const priorReceiveSig = readReceiveSigFromBundle(rawBundle); + if (rawBundle && isRecord(rawBundle)) { + const receiveSigHistory = collectReceiveSigHistory(rawBundle, priorReceiveSig); + if (receiveSigHistory.length > 0) { + nextBundle.receiveSigHistory = receiveSigHistory; + } + } + delete nextBundle.receiveSig; + + const bundleRoot = buildBundleRoot(nextBundle); + const rootHash = await computeBundleHash(bundleRoot); + const legacySeed = { ...nextBundle } as Record; + delete legacySeed.bundleRoot; + delete legacySeed.transport; + delete legacySeed.verificationCache; + delete legacySeed.cacheKey; + delete legacySeed.receipt; + delete legacySeed.receiptHash; + delete legacySeed.verificationSig; + delete legacySeed.zkMeta; + const legacyUnsigned = buildBundleUnsigned(legacySeed); + const legacyHash = await hashBundle(legacyUnsigned); + const useRootHash = + proofBundleMeta?.bundleRoot !== undefined || + proofBundleMeta?.bindings?.bundleHashOf === PROOF_BINDINGS.bundleHashOf; + const bundleHashNext = useRootHash ? rootHash : legacyHash; + if (useRootHash) { + nextBundle.bundleRoot = bundleRoot; } else { - nextBundle = { svgHash, capsuleHash, ...(receiveSigLocal ? { receiveSig: receiveSigLocal } : {}) }; + delete nextBundle.bundleRoot; } + const computeReceiveBundleHash = async (pulse: number) => { + const receiveBundleRoot = buildReceiveBundleRoot({ + bundleRoot, + bundle: nextBundle as ProofBundleLike, + originBundleHash, + originAuthorSig, + receivePulse: pulse, + }); + const receiveBundleHash = await hashReceiveBundleRoot(receiveBundleRoot); + return { receiveBundleRoot, receiveBundleHash }; + }; - const bundleUnsigned = buildBundleUnsigned(nextBundle); - const bundleHashNext = await hashBundle(bundleUnsigned); + let { receiveBundleRoot, receiveBundleHash } = await computeReceiveBundleHash(receivePulse); + if (receiveSigLocal && receiveSigLocal.binds.bundleHash !== receiveBundleHash) { + receiveSigLocal = null; + receivePulse = nowPulse; + ({ receiveBundleRoot, receiveBundleHash } = await computeReceiveBundleHash(receivePulse)); + } + if (receiveStatus === "new" && !receiveSigLocal) { + receiveSigLocal = await claimReceiveSig(receiveBundleHash, receivePulse); + if (!receiveSigLocal) return; + } nextBundle.bundleHash = bundleHashNext; + nextBundle.receiveBundleHash = receiveBundleHash; + nextBundle.receiveBundleRoot = receiveBundleRoot; + if (receiveSigLocal) nextBundle.receiveSig = receiveSigLocal; + + if (receiveSigLocal) { + const ownerPhiKey = await deriveOwnerPhiKeyFromReceive({ + receiverPubKeyJwk: receiveSigLocal.pubKeyJwk, + receivePulse, + receiveBundleHash, + }); + nextBundle.ownerPhiKey = ownerPhiKey; + nextBundle.ownerKeyDerivation = buildOwnerKeyDerivation({ + originPhiKey: proofCapsule?.phiKey, + receivePulse, + receiveBundleHash, + }); + } const updatedSvg = embedProofMetadata(baseSvg, nextBundle); - durl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(updatedSvg)))}`; const sigilPulse = updated.pulse ?? 0; - download(durl, `${pulseFilename("sigil_receive", sigilPulse, nowPulse)}.svg`); + const receiveBase = pulseFilename("sigil_bundle", sigilPulse, nowPulse); + const receiveZip = await buildBundleZip({ + svgText: updatedSvg, + meta: updated, + base: receiveBase, + context: "receive", + proofBundle: nextBundle, + }); + download(receiveZip, `${receiveBase}.zip`); const receivedPhi = readPhiAmountFromMeta(updated); const receivedPhiNumber = receivedPhi ? Number(receivedPhi) : NaN; dispatchPhiMoveSuccess({ @@ -2410,8 +2857,7 @@ const VerifierStamperInner: React.FC = () => { amountPhiDisplay: receivedPhi ? `Φ ${fmtPhiFixed4(receivedPhi)}` : undefined, amountDisplay: receivedPhi ? `Φ ${fmtPhiFixed4(receivedPhi)}` : undefined, amountPhi: Number.isFinite(receivedPhiNumber) ? receivedPhiNumber : undefined, - downloadUrl: durl, - downloadLabel: "Sigil Receive", + downloadLabel: "Sigil Bundle", message: "Transfer received.", }); const updated2 = await refreshHeadWindow(updated); @@ -2426,19 +2872,60 @@ const VerifierStamperInner: React.FC = () => { return; } const { meta: rolled, segmentFileBlob } = await sealCurrentWindowIntoSegment(meta); - if (segmentFileBlob) + const nowPulse = kaiPulseNow(); + const rolledSendLock = (rolled as SigilMetadataWithOptionals).sendLock; + if (rolledSendLock?.nonce) { + (rolled as SigilMetadataWithOptionals).sendLock = { + ...rolledSendLock, + used: true, + usedPulse: nowPulse, + }; + } + const segmented = await buildSegmentedSvgDataUrl(rolled); + if (segmented) { + const segmentName = `sigil_segment_${rolled.pulse ?? 0}_${String((rolled.segments?.length ?? 1) - 1).padStart(6, "0")}.json`; + const base = pulseFilename("sigil_segment_bundle", rolled.pulse ?? 0, nowPulse); + const zipBlob = await buildBundleZip({ + svgText: segmented.svgText, + meta: rolled, + base, + context: "segment", + proofBundle: segmented.proofBundle ?? null, + segmentFile: segmentFileBlob ? { name: segmentName, blob: segmentFileBlob } : null, + }); + download(zipBlob, `${base}.zip`); + } else if (segmentFileBlob) { download( segmentFileBlob, `sigil_segment_${rolled.pulse ?? 0}_${String((rolled.segments?.length ?? 1) - 1).padStart(6, "0")}.json` ); - if (svgURL) { - const durl = await embedMetadata(svgURL, rolled); - download(durl, `${pulseFilename("sigil_head_after_seal", rolled.pulse ?? 0, kaiPulseNow())}.svg`); + } + const hasReceiveProof = + Boolean((rolled as SigilMetadataWithOptionals).receiveSig) || + Boolean(proofBundleMeta?.receiveSig) || + (isRecord(proofBundleMeta?.raw) && proofBundleMeta?.raw?.mode === "receive"); + if (hasReceiveProof) { + const eff = await computeEffectiveCanonical(rolled); + await markSegmentedAsReceived(rolled, nowPulse, { + bundleHash: segmented?.bundleHash ?? bundleHash, + canonicalOverride: eff.canonical, + }); } const rolled2 = await refreshHeadWindow(rolled); await syncMetaAndUi(rolled2); setError(null); - }, [meta, svgURL, isSendFilename, refreshHeadWindow, syncMetaAndUi]); + }, [ + meta, + isSendFilename, + refreshHeadWindow, + syncMetaAndUi, + buildBundleZip, + buildSegmentedSvgDataUrl, + bundleHash, + computeEffectiveCanonical, + markSegmentedAsReceived, + proofBundleMeta, + ]); const frequencyHz = useMemo( () => @@ -2509,6 +2996,33 @@ const VerifierStamperInner: React.FC = () => { } }, [authorSigValue]); + const shareText = useMemo(() => { + const isVerified = uiState === "verified" || typeof proofBundleMeta?.verifiedAtPulse === "number"; + if (!isVerified) return ""; + const pulse = meta?.pulse; + const verifiedAt = proofBundleMeta?.verifiedAtPulse; + const capsulePhiKey = + (proofBundleMeta?.proofCapsule as { phiKey?: string } | null | undefined)?.phiKey ?? null; + const phiKey = meta?.userPhiKey ?? capsulePhiKey ?? ""; + const phiKeyShort = phiKey ? shortenPhiKey(phiKey) : ""; + const kasOk = Boolean(authorSigValue && isKASAuthorSig(authorSigValue)); + const g16Ok = Boolean(zkProof); + const parts: string[] = ["VERIFIED"]; + if (typeof pulse === "number" && Number.isFinite(pulse)) { + if (typeof verifiedAt === "number" && Number.isFinite(verifiedAt)) { + parts.push(`Pulse ${pulse} verified stewardship at pulse ${verifiedAt}`); + } else { + parts.push(`Pulse ${pulse}`); + } + } else if (typeof verifiedAt === "number" && Number.isFinite(verifiedAt)) { + parts.push(`Verified stewardship at pulse ${verifiedAt}`); + } + if (phiKeyShort) parts.push(`ΦKey ${phiKeyShort}`); + parts.push(`KAS ${kasOk ? "✅" : "❌"}`); + parts.push(`G16 ${g16Ok ? "✅" : "❌"}`); + return parts.join(" • "); + }, [authorSigValue, meta?.pulse, meta?.userPhiKey, proofBundleMeta?.proofCapsule, proofBundleMeta?.verifiedAtPulse, uiState, zkProof]); + // Chakra: resolve from chakraDay or chakraGate (strips "gate" implicitly) const chakraDayDisplay = useMemo(() => resolveChakraDay(meta ?? {}), [meta]); @@ -3161,6 +3675,7 @@ const VerifierStamperInner: React.FC = () => { open={sealOpen} url={sealUrl} hash={sealHash} + shareText={shareText || undefined} onClose={() => { setSealOpen(false); setRotateOut(false); diff --git a/src/components/valuation/chart/LiveChart.tsx b/src/components/valuation/chart/LiveChart.tsx index d18679d0d..4b3bc5d81 100644 --- a/src/components/valuation/chart/LiveChart.tsx +++ b/src/components/valuation/chart/LiveChart.tsx @@ -58,6 +58,9 @@ export type LiveChartProps = { /** If you know it's a child glyph, pass true to force USD mode. */ isChildGlyph?: boolean; + /** Unit of incoming data points (default: phi). */ + dataUnit?: "phi" | "usd"; + /** * Force chart unit mode. * - "auto" (default): child => USD, parent => Φ @@ -133,6 +136,7 @@ export default function LiveChart({ scalePvToChild = true, usdPerPhi, isChildGlyph = false, + dataUnit = "phi", mode = "auto", }: LiveChartProps) { // Container & width @@ -145,23 +149,7 @@ export default function LiveChart({ const dataMax = hasData ? safeData[safeData.length - 1].i : 1; const lastIndex = hasData ? safeData[safeData.length - 1].i : 0; const lastParentValue = hasData ? safeData[safeData.length - 1].value : live; - - // Detect child glyph (explicit or live differs from parent last tick) - const childΦ = useMemo(() => { - if (childPhiExact != null && Number.isFinite(childPhiExact)) return childPhiExact; - const diff = Math.abs(live - lastParentValue); - return diff > 1e-9 ? live : null; - }, [childPhiExact, live, lastParentValue]); - - // Force child mode from prop if known - const isChild = isChildGlyph || childΦ != null; - - // Unit mode (Φ vs USD) - const isUsdMode = useMemo(() => { - if (mode === "usd") return true; - if (mode === "phi") return false; - return isChild; // auto - }, [mode, isChild]); + const lastPoint = hasData ? (safeData[safeData.length - 1] as FXPoint) : null; /* ─────────────────── STABLE FX LATCH ─────────────────── * Remember last known positive FX and use it whenever a new @@ -181,6 +169,31 @@ export default function LiveChart({ [usdPerPhi], ); + const lastParentPhi = useMemo(() => { + if (!hasData) return live; + if (dataUnit !== "usd") return Number(lastParentValue); + const fx = lastPoint ? fxOf(lastPoint) : usdPerPhi; + if (!finitePos(fx)) return Number(lastParentValue); + return Number(lastParentValue) / (fx as number); + }, [dataUnit, fxOf, hasData, lastParentValue, lastPoint, live, usdPerPhi]); + + // Detect child glyph (explicit or live differs from parent last tick) + const childΦ = useMemo(() => { + if (childPhiExact != null && Number.isFinite(childPhiExact)) return childPhiExact; + const diff = Math.abs(live - lastParentPhi); + return diff > 1e-9 ? live : null; + }, [childPhiExact, live, lastParentPhi]); + + // Force child mode from prop if known + const isChild = isChildGlyph || childΦ != null; + + // Unit mode (Φ vs USD) + const isUsdMode = useMemo(() => { + if (mode === "usd") return true; + if (mode === "phi") return false; + return isChild; // auto + }, [mode, isChild]); + /** USD for a point based on Φ×FX or provided USD, with stability guard */ const usdFromPoint = useCallback( (p: FXPoint, prevUsd: number | null): [number, number] => { @@ -209,7 +222,7 @@ export default function LiveChart({ // Build plot series in correct units (Φ or USD) const plotData = useMemo(() => { if (!hasData) return safeData; - if (!isUsdMode) return safeData; // Φ mode + if (!isUsdMode || dataUnit === "usd") return safeData; // Φ mode or USD-native series let lastGoodUsd: number | null = null; return safeData.map((p) => { @@ -218,16 +231,16 @@ export default function LiveChart({ lastGoodUsd = nextGood; return { ...p, value: usdV }; }); - }, [hasData, safeData, isUsdMode, usdFromPoint]); + }, [hasData, safeData, isUsdMode, dataUnit, usdFromPoint]); // PV display in Φ, optionally scaled by child ratio const pvPhi = useMemo(() => { - if (!scalePvToChild || childΦ == null || !Number.isFinite(lastParentValue) || lastParentValue <= 0) { + if (!scalePvToChild || childΦ == null || !Number.isFinite(lastParentPhi) || lastParentPhi <= 0) { return pv; } - const r = childΦ / lastParentValue; + const r = childΦ / lastParentPhi; return pv * r; - }, [pv, scalePvToChild, childΦ, lastParentValue]); + }, [pv, scalePvToChild, childΦ, lastParentPhi]); // PV line in chart units const pvChart = useMemo(() => (isUsdMode ? pvPhi * fxOf() : pvPhi), [isUsdMode, pvPhi, fxOf]); diff --git a/src/entry-server-exports.ts b/src/entry-server-exports.ts new file mode 100644 index 000000000..e7dc47567 --- /dev/null +++ b/src/entry-server-exports.ts @@ -0,0 +1,5 @@ +export { safeJsonStringify, stableJsonStringify, buildSnapshotEntries, LruTtlCache } from "./ssr/serverExports"; +export { renderVerifiedOgPng } from "./og/renderVerifiedOg"; +export { renderNotFoundOgPng } from "./og/renderNotFoundOg"; +export { getCapsuleByHash, getCapsuleByVerifierSlug } from "./og/capsuleStore"; +export { LruTtlCache as OgLruTtlCache } from "./og/cache"; diff --git a/src/lib/download.ts b/src/lib/download.ts index ed9a14d95..ee8ff6d8a 100644 --- a/src/lib/download.ts +++ b/src/lib/download.ts @@ -1,9 +1,13 @@ export function downloadBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + window.setTimeout(() => { URL.revokeObjectURL(url); - } - \ No newline at end of file + a.remove(); + }, 1000); +} diff --git a/src/og/buildVerifiedCardSvg.ts b/src/og/buildVerifiedCardSvg.ts new file mode 100644 index 000000000..b408910d7 --- /dev/null +++ b/src/og/buildVerifiedCardSvg.ts @@ -0,0 +1,246 @@ +import phiSvg from "../assets/phi.svg?raw"; +import type { VerifiedCardData } from "./types"; +import { sanitizeSigilSvg, svgToDataUri } from "./sigilEmbed"; +import { currency as fmtPhi, usd as fmtUsd } from "../components/valuation/display"; + +const WIDTH = 1200; +const HEIGHT = 630; +const phiLogoDataUri = svgToDataUri(phiSvg); + +function hashStringToInt(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +} + +function accentFromHash(capsuleHash: string): { accent: string; accentSoft: string; accentGlow: string } { + const hash = hashStringToInt(capsuleHash); + const hue = hash % 360; + const accent = `hsl(${hue} 78% 62%)`; + const accentSoft = `hsl(${hue} 78% 52%)`; + const accentGlow = `hsla(${hue}, 90%, 70%, 0.75)`; + return { accent, accentSoft, accentGlow }; +} + +function shortPhiKey(phiKey: string): string { + const trimmed = phiKey.trim(); + if (trimmed.length <= 14) return trimmed; + return `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}`; +} + +function badgeMark(ok: boolean): string { + if (ok) { + return "M20 34 L28 42 L44 20"; + } + return "M20 20 L44 44 M44 20 L20 44"; +} + +function headerCheckPath(): string { + return "M16 26 L26 36 L44 16"; +} + +function dropUndefined>(value: T): T { + const entries = Object.entries(value).filter((entry) => entry[1] !== undefined); + return Object.fromEntries(entries) as T; +} + +function formatPhiValue(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return fmtPhi(value); +} + +function formatUsdValue(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return fmtUsd(value); +} + +function sigilImageMarkup(sigilSvg: string | undefined, clipId: string): string { + if (!sigilSvg) { + return ` + + Sigil unavailable + `; + } + const sanitized = sanitizeSigilSvg(sigilSvg); + const dataUri = svgToDataUri(sanitized); + return ` + + `; +} + +export function buildVerifiedCardSvg(data: VerifiedCardData): string { + const { capsuleHash, verifiedAtPulse, phikey, kasOk, g16Ok, sigilSvg } = data; + const { accent, accentSoft, accentGlow } = accentFromHash(capsuleHash); + const id = `og-${hashStringToInt(capsuleHash).toString(16)}`; + const sigilClipId = `${id}-sigil-clip`; + const ringGradientId = `${id}-ring`; + const glowId = `${id}-glow`; + const waveId = `${id}-wave`; + const badgeGlowId = `${id}-badge-glow`; + + const phiShort = shortPhiKey(phikey); + const valuationSnapshot = data.valuation ? { ...data.valuation } : undefined; + if (valuationSnapshot && "valuationHash" in valuationSnapshot) { + delete (valuationSnapshot as { valuationHash?: string }).valuationHash; + } + const valuationHash = data.valuation?.valuationHash ?? data.receipt?.valuationHash; + const valuationPhi = formatPhiValue(valuationSnapshot?.phiValue); + const valuationUsd = formatUsdValue(valuationSnapshot?.usdValue); + const valuationModeLabel = + valuationSnapshot?.mode === "receive" ? "RECEIVE" : valuationSnapshot?.mode === "origin" ? "ORIGIN" : null; + + const receiptPayload = + data.receipt ?? + (data.bundleHash && data.zkPoseidonHash && data.verificationVersion && data.verifier + ? { + v: "KVR-1", + bundleHash: data.bundleHash, + zkPoseidonHash: data.zkPoseidonHash, + verifiedAtPulse, + verifier: data.verifier, + verificationVersion: data.verificationVersion, + } + : undefined); + const receiptMeta: Record = {}; + const bundleHash = receiptPayload?.bundleHash ?? data.bundleHash; + const zkPoseidonHash = receiptPayload?.zkPoseidonHash ?? data.zkPoseidonHash; + const verifier = receiptPayload?.verifier ?? data.verifier; + const verificationVersion = receiptPayload?.verificationVersion ?? data.verificationVersion; + if (bundleHash) receiptMeta.bundleHash = bundleHash; + if (zkPoseidonHash) receiptMeta.zkPoseidonHash = zkPoseidonHash; + if (verifier) receiptMeta.verifier = verifier; + if (verificationVersion) receiptMeta.verificationVersion = verificationVersion; + receiptMeta.verifiedAtPulse = receiptPayload?.verifiedAtPulse ?? verifiedAtPulse; + if (receiptPayload) receiptMeta.receipt = receiptPayload; + if (data.receiptHash) receiptMeta.receiptHash = data.receiptHash; + if (data.verificationSig) receiptMeta.verificationSig = data.verificationSig; + const receiptJson = JSON.stringify(receiptMeta); + + const auditMeta = dropUndefined({ + receiptHash: data.receiptHash, + valuation: valuationSnapshot, + valuationHash, + bundleHash, + zkPoseidonHash, + verifiedAtPulse: receiptPayload?.verifiedAtPulse ?? verifiedAtPulse, + }); + const auditJson = JSON.stringify(auditMeta); + + return ` + + ${receiptJson} + ${auditJson} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VERIFIED + ${valuationModeLabel ? `${valuationModeLabel}` : ""} + + + + + + + Steward Verified @ Pulse ${verifiedAtPulse} • ΦKey + + ${phiShort} + + KAS + + + + + + G16 + + + + + + Φ VALUE (MINTED) + ${valuationPhi} + + USD VALUE (MINTED) + ${valuationUsd} + + + + ${sigilImageMarkup(sigilSvg, sigilClipId)} + + + Proof of Breath™ — VERIFIED + phi.network + + `.trim(); +} diff --git a/src/og/cache.ts b/src/og/cache.ts new file mode 100644 index 000000000..076ea8df2 --- /dev/null +++ b/src/og/cache.ts @@ -0,0 +1,51 @@ +export type LruTtlCacheOptions = { + maxEntries: number; + ttlMs: number; +}; + +type CacheEntry = { + value: V; + expiresAtMs: number; +}; + +export class LruTtlCache { + private readonly maxEntries: number; + private readonly ttlMs: number; + private readonly store = new Map>(); + + constructor(options: LruTtlCacheOptions) { + this.maxEntries = Math.max(1, Math.floor(options.maxEntries)); + this.ttlMs = Math.max(0, Math.floor(options.ttlMs)); + } + + get(key: K): V | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + const now = Date.now(); + if (entry.expiresAtMs > 0 && entry.expiresAtMs <= now) { + this.store.delete(key); + return undefined; + } + this.store.delete(key); + this.store.set(key, entry); + return entry.value; + } + + set(key: K, value: V, ttlMs?: number): void { + const ttl = ttlMs == null ? this.ttlMs : Math.max(0, Math.floor(ttlMs)); + const expiresAtMs = ttl > 0 ? Date.now() + ttl : 0; + if (this.store.has(key)) { + this.store.delete(key); + } + this.store.set(key, { value, expiresAtMs }); + while (this.store.size > this.maxEntries) { + const firstKey = this.store.keys().next().value as K | undefined; + if (firstKey === undefined) break; + this.store.delete(firstKey); + } + } + + clear(): void { + this.store.clear(); + } +} diff --git a/src/og/capsuleStore.ts b/src/og/capsuleStore.ts new file mode 100644 index 000000000..c510ea1da --- /dev/null +++ b/src/og/capsuleStore.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { VerifiedCardData } from "./types"; + +const DEFAULT_PATHS = [ + "data/verified-capsules.json", + "data/verified_capsules.json", + "public/verified-capsules.json", + "public/verified_capsules.json", +]; + +type CapsuleIndex = { + byHash: Map; + bySlug: Map; + mtimeMs: number; + storePath: string | null; +}; + +let cache: CapsuleIndex = { + byHash: new Map(), + bySlug: new Map(), + mtimeMs: 0, + storePath: null, +}; + +function resolveStorePath(): string | null { + const envPath = process.env.PHI_CAPSULE_INDEX_PATH || process.env.PHI_CAPSULE_INDEX; + const candidates = envPath ? [envPath, ...DEFAULT_PATHS] : DEFAULT_PATHS; + + for (const candidate of candidates) { + const resolved = path.resolve(process.cwd(), candidate); + if (fs.existsSync(resolved)) return resolved; + } + + return null; +} + +function parseBoolean(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "ok", "verified"].includes(normalized)) return true; + if (["0", "false", "no", "invalid", "failed"].includes(normalized)) return false; + } + return null; +} + +function parseRecord(raw: unknown): VerifiedCardData | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + const capsuleHash = typeof record.capsuleHash === "string" ? record.capsuleHash : null; + const pulseValue = record.pulse; + const pulse = typeof pulseValue === "number" && Number.isFinite(pulseValue) ? pulseValue : null; + const verifiedAtPulseValue = + record.verifiedAtPulse ?? + record.verified_at_pulse ?? + record.verifiedPulse ?? + record.verified_pulse ?? + record.verificationPulse; + const verifiedAtPulse = + typeof verifiedAtPulseValue === "number" && Number.isFinite(verifiedAtPulseValue) + ? verifiedAtPulseValue + : typeof verifiedAtPulseValue === "string" && verifiedAtPulseValue.trim() !== "" && Number.isFinite(Number(verifiedAtPulseValue)) + ? Number(verifiedAtPulseValue) + : null; + const phiKey = typeof record.phikey === "string" + ? record.phikey + : typeof record.phiKey === "string" + ? record.phiKey + : null; + const kasOk = parseBoolean(record.kasOk); + const g16Ok = parseBoolean(record.g16Ok); + const verifierSlug = typeof record.verifierSlug === "string" ? record.verifierSlug : undefined; + const sigilSvg = typeof record.sigilSvg === "string" ? record.sigilSvg : undefined; + const verifier = typeof record.verifier === "string" ? record.verifier : undefined; + const verificationVersion = typeof record.verificationVersion === "string" ? record.verificationVersion : undefined; + const bundleHash = typeof record.bundleHash === "string" ? record.bundleHash : undefined; + const zkPoseidonHash = typeof record.zkPoseidonHash === "string" ? record.zkPoseidonHash : undefined; + const receipt = typeof record.receipt === "object" && record.receipt !== null ? (record.receipt as VerifiedCardData["receipt"]) : undefined; + const receiptHash = typeof record.receiptHash === "string" ? record.receiptHash : undefined; + const verificationSig = + typeof record.verificationSig === "object" && record.verificationSig !== null + ? (record.verificationSig as VerifiedCardData["verificationSig"]) + : undefined; + const valuation = + typeof record.valuation === "object" && record.valuation !== null ? (record.valuation as VerifiedCardData["valuation"]) : undefined; + + if (!capsuleHash || pulse == null || !phiKey || kasOk == null || g16Ok == null) return null; + + return { + capsuleHash, + pulse, + verifiedAtPulse: verifiedAtPulse ?? pulse, + phikey: phiKey, + kasOk, + g16Ok, + verifierSlug, + verifier, + verificationVersion, + bundleHash, + zkPoseidonHash, + receipt, + receiptHash, + verificationSig, + sigilSvg, + valuation, + }; +} + +function loadIndex(): CapsuleIndex { + const storePath = resolveStorePath(); + if (!storePath) { + cache = { byHash: new Map(), bySlug: new Map(), mtimeMs: 0, storePath: null }; + return cache; + } + + const stats = fs.statSync(storePath); + if (cache.storePath === storePath && cache.mtimeMs === stats.mtimeMs) { + return cache; + } + + const rawText = fs.readFileSync(storePath, "utf8"); + const parsed = JSON.parse(rawText) as unknown; + const records: VerifiedCardData[] = []; + + if (Array.isArray(parsed)) { + for (const entry of parsed) { + const rec = parseRecord(entry); + if (rec) records.push(rec); + } + } else if (parsed && typeof parsed === "object") { + const parsedRecord = parseRecord(parsed); + if (parsedRecord) { + records.push(parsedRecord); + } else { + const container = parsed as Record; + if (Array.isArray(container.records)) { + for (const entry of container.records) { + const rec = parseRecord(entry); + if (rec) records.push(rec); + } + } else { + for (const entry of Object.values(container)) { + const rec = parseRecord(entry); + if (rec) records.push(rec); + } + } + } + } + + const byHash = new Map(); + const bySlug = new Map(); + for (const record of records) { + byHash.set(record.capsuleHash, record); + if (record.verifierSlug) { + bySlug.set(record.verifierSlug.toLowerCase(), record); + } + } + + cache = { byHash, bySlug, mtimeMs: stats.mtimeMs, storePath }; + return cache; +} + +export function getCapsuleByHash(capsuleHash: string): VerifiedCardData | null { + const index = loadIndex(); + return index.byHash.get(capsuleHash) ?? null; +} + +export function getCapsuleByVerifierSlug(verifierSlug: string): VerifiedCardData | null { + const index = loadIndex(); + return index.bySlug.get(verifierSlug.toLowerCase()) ?? null; +} diff --git a/src/og/downloadVerifiedCard.ts b/src/og/downloadVerifiedCard.ts new file mode 100644 index 000000000..9731063d5 --- /dev/null +++ b/src/og/downloadVerifiedCard.ts @@ -0,0 +1,31 @@ +import { downloadBlob } from "../lib/download"; +import type { VerifiedCardData } from "./types"; +import { buildVerifiedCardSvg } from "./buildVerifiedCardSvg"; +import { svgToPngBlob } from "./svgToPng"; + +function fileNameForCapsule(hash: string, verifiedAtPulse: number): string { + const safe = hash.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 16) || "verified"; + return `verified-${safe}-${verifiedAtPulse}.png`; +} + +export async function downloadVerifiedCardPng(data: VerifiedCardData): Promise { + const filename = fileNameForCapsule(data.capsuleHash, data.verifiedAtPulse); + const ogUrl = `/og/v/verified/${encodeURIComponent(data.capsuleHash)}/${encodeURIComponent(String(data.verifiedAtPulse))}.png`; + + try { + const res = await fetch(ogUrl, { method: "GET" }); + const contentType = res.headers.get("content-type") || ""; + const notFoundHeader = res.headers.get("x-og-not-found"); + if (res.ok && !notFoundHeader && contentType.toLowerCase().startsWith("image/png")) { + const blob = await res.blob(); + downloadBlob(blob, filename); + return; + } + } catch { + // Fall back to client render + } + + const svg = buildVerifiedCardSvg(data); + const pngBlob = await svgToPngBlob(svg, 1200, 630); + downloadBlob(pngBlob, filename); +} diff --git a/src/og/renderNotFoundOg.ts b/src/og/renderNotFoundOg.ts new file mode 100644 index 000000000..1b0caf26b --- /dev/null +++ b/src/og/renderNotFoundOg.ts @@ -0,0 +1,59 @@ +import { Resvg } from "@resvg/resvg-js"; + +const WIDTH = 1200; +const HEIGHT = 630; + +const escapeXml = (value: string): string => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +function buildNotFoundSvg(capsuleHash: string): string { + const safeHash = escapeXml(capsuleHash || "unknown"); + return ` + + + + + + + + + + + + + + + + + + + + + Φ + + NOT FOUND + Proof capsule unavailable + ${safeHash} + + + Proof of Breath™ — NOT FOUND + phi.network + + `.trim(); +} + +export function renderNotFoundOgPng(capsuleHash: string): Buffer { + const svg = buildNotFoundSvg(capsuleHash); + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); + const pngData = resvg.render().asPng(); + return Buffer.from(pngData); +} diff --git a/src/og/renderVerifiedOg.ts b/src/og/renderVerifiedOg.ts new file mode 100644 index 000000000..d6a4c1ce3 --- /dev/null +++ b/src/og/renderVerifiedOg.ts @@ -0,0 +1,12 @@ +import { Resvg } from "@resvg/resvg-js"; +import type { VerifiedCardData } from "./types"; +import { buildVerifiedCardSvg } from "./buildVerifiedCardSvg"; + +export function renderVerifiedOgPng(data: VerifiedCardData): Buffer { + const svg = buildVerifiedCardSvg(data); + const resvg = new Resvg(svg, { + fitTo: { mode: "width", value: 1200 }, + }); + const pngData = resvg.render().asPng(); + return Buffer.from(pngData); +} diff --git a/src/og/sigilEmbed.ts b/src/og/sigilEmbed.ts new file mode 100644 index 000000000..b8c32226c --- /dev/null +++ b/src/og/sigilEmbed.ts @@ -0,0 +1,44 @@ +const SCRIPT_TAG = /]*>[\s\S]*?<\/script>/gi; +const FOREIGN_OBJECT_TAG = /]*>[\s\S]*?<\/foreignObject>/gi; +const EVENT_HANDLER_ATTR = /\son[a-zA-Z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi; +const JS_PROTOCOL_ATTR = /\s(xlink:href|href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi; + +function stripProhibitedTags(svg: string): string { + return svg.replace(SCRIPT_TAG, "").replace(FOREIGN_OBJECT_TAG, ""); +} + +function stripEventHandlers(svg: string): string { + return svg.replace(EVENT_HANDLER_ATTR, ""); +} + +function sanitizeHrefValue(raw: string): string { + const value = raw.trim().replace(/^['"]|['"]$/g, ""); + const lower = value.toLowerCase(); + if (lower.startsWith("javascript:")) return ""; + if (lower.startsWith("http://") || lower.startsWith("https://")) return ""; + return value; +} + +function stripUnsafeHrefs(svg: string): string { + return svg.replace(JS_PROTOCOL_ATTR, (match, attr, value) => { + const sanitized = sanitizeHrefValue(String(value)); + if (!sanitized) return ""; + return ` ${String(attr)}=\"${sanitized}\"`; + }); +} + +export function sanitizeSigilSvg(svg: string): string { + const raw = svg ?? ""; + const withoutTags = stripProhibitedTags(raw); + const withoutHandlers = stripEventHandlers(withoutTags); + return stripUnsafeHrefs(withoutHandlers); +} + +export function svgToDataUri(svg: string): string { + const cleaned = sanitizeSigilSvg(svg); + const encoded = encodeURIComponent(cleaned) + .replace(/%0A/g, "") + .replace(/%0D/g, "") + .replace(/%09/g, ""); + return `data:image/svg+xml;utf8,${encoded}`; +} diff --git a/src/og/svgToPng.ts b/src/og/svgToPng.ts new file mode 100644 index 000000000..35eb4f152 --- /dev/null +++ b/src/og/svgToPng.ts @@ -0,0 +1,58 @@ +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + let settled = false; + + const finalizeResolve = () => { + if (settled) return; + settled = true; + resolve(img); + }; + + const finalizeReject = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + + img.onload = () => finalizeResolve(); + img.onerror = () => finalizeReject(new Error("Failed to load SVG image")); + img.decoding = "async"; + img.src = url; + + if (typeof img.decode === "function") { + img + .decode() + .then(() => finalizeResolve()) + .catch(() => { + if (!settled) finalizeResolve(); + }); + } + }); +} + +export async function svgToPngBlob(svg: string, width: number, height: number): Promise { + const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + try { + const img = await loadImage(url); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Canvas 2D context unavailable"); + } + ctx.clearRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((result) => { + if (result) resolve(result); + else reject(new Error("Failed to encode PNG")); + }, "image/png"); + }); + return blob; + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/src/og/types.ts b/src/og/types.ts new file mode 100644 index 000000000..11b4fa697 --- /dev/null +++ b/src/og/types.ts @@ -0,0 +1,23 @@ +import type { VerificationReceipt, VerificationSig } from "../utils/verificationReceipt"; +import type { ValuationSnapshot } from "../utils/valuationSnapshot"; + +export type VerifiedCardValuation = ValuationSnapshot & { valuationHash?: string }; + +export type VerifiedCardData = { + capsuleHash: string; + pulse: number; + verifiedAtPulse: number; + phikey: string; + kasOk: boolean; + g16Ok: boolean; + verifierSlug?: string; + verifier?: string; + verificationVersion?: string; + bundleHash?: string; + zkPoseidonHash?: string; + receipt?: VerificationReceipt; + receiptHash?: string; + verificationSig?: VerificationSig; + sigilSvg?: string; + valuation?: VerifiedCardValuation; +}; diff --git a/src/pages/SigilPage/exportZip.ts b/src/pages/SigilPage/exportZip.ts index 2c79d761e..aeac5ce9d 100644 --- a/src/pages/SigilPage/exportZip.ts +++ b/src/pages/SigilPage/exportZip.ts @@ -20,14 +20,21 @@ import { buildProofHints, generateZkProofFromPoseidonHash } from "../../utils/zk import { computeZkPoseidonHash } from "../../utils/kai"; import { ensureTitleAndDesc, ensureViewBoxOnClone, ensureXmlns } from "../../utils/svgMeta"; import { - buildBundleUnsigned, + buildBundleRoot, + buildZkPublicInputs, buildVerifierUrl, - hashBundle, + computeBundleHash, hashProofCapsuleV1, hashSvgText, + normalizeProofBundleZkCurves, normalizeChakraDay, PROOF_CANON, + PROOF_BINDINGS, PROOF_HASH_ALG, + ZK_PUBLIC_INPUTS_CONTRACT, + ZK_STATEMENT_BINDING, + ZK_STATEMENT_ENCODING, + ZK_STATEMENT_DOMAIN, type ProofCapsuleV1, } from "../../components/KaiVoh/verifierProof"; import type { SigilProofHints } from "../../types/sigil"; @@ -566,8 +573,9 @@ export async function exportZIP(ctx: { if (!allowMissingProof) throw new Error("ZK proof missing"); } - if (zkPublicInputs) { - svgClone.setAttribute("data-zk-public-inputs", JSON.stringify(zkPublicInputs)); + const normalizedZkPublicInputs = zkPoseidonHash ? buildZkPublicInputs(zkPoseidonHash) : zkPublicInputs; + if (normalizedZkPublicInputs) { + svgClone.setAttribute("data-zk-public-inputs", JSON.stringify(normalizedZkPublicInputs)); } if (zkPoseidonHash) { svgClone.setAttribute("data-zk-scheme", "groth16-poseidon"); @@ -585,28 +593,53 @@ export async function exportZIP(ctx: { const svgHash = await hashSvgText(svgString); + const zkStatement = zkPoseidonHash + ? { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT, + encoding: ZK_STATEMENT_ENCODING, + } + : undefined; + const zkMeta = zkPoseidonHash + ? { + protocol: "groth16", + scheme: "groth16-poseidon", + circuitId: "sigil_proof", + } + : undefined; + const normalizedZk = normalizeProofBundleZkCurves({ zkProof, zkMeta }); + zkProof = normalizedZk.zkProof; + const zkMetaNormalized = normalizedZk.zkMeta; const proofBundleBase = { hashAlg: PROOF_HASH_ALG, canon: PROOF_CANON, + bindings: PROOF_BINDINGS, + zkStatement, proofCapsule, capsuleHash, svgHash, - shareUrl, - verifierUrl, - authorSig: null, zkPoseidonHash, zkProof, - proofHints, - zkPublicInputs, + zkPublicInputs: normalizedZkPublicInputs, + zkMeta: zkMetaNormalized, }; - const bundleUnsigned = buildBundleUnsigned(proofBundleBase); - const computedBundleHash = await hashBundle(bundleUnsigned); + const transport = { + shareUrl, + verifierUrl, + proofHints, + }; + const bundleRoot = buildBundleRoot(proofBundleBase); + const computedBundleHash = await computeBundleHash(bundleRoot); const proofBundle = { ...proofBundleBase, + bundleRoot, bundleHash: computedBundleHash, authorSig: null, + transport, + proofHints, }; const sealedSvg = embedProofMetadata(svgString, proofBundle); diff --git a/src/pages/VerifyPage.tsx b/src/pages/VerifyPage.tsx index ad6c0a9ef..ec351caaa 100644 --- a/src/pages/VerifyPage.tsx +++ b/src/pages/VerifyPage.tsx @@ -10,38 +10,79 @@ import { DEFAULT_ISSUANCE_POLICY, quotePhiForUsd } from "../utils/phi-issuance"; import { currency as fmtPhi, usd as fmtUsd } from "../components/valuation/display"; import LiveChart from "../components/valuation/chart/LiveChart"; import { + assertZkCurveConsistency, + assertZkPublicInputsContract, buildVerifierSlug, buildVerifierUrl, + buildBundleRoot, buildBundleUnsigned, + computeBundleHash, hashBundle, hashProofCapsuleV1, hashSvgText, normalizeChakraDay, + normalizeBundle, PROOF_CANON, + PROOF_BINDINGS, PROOF_HASH_ALG, + ZK_PUBLIC_INPUTS_CONTRACT, + VERIFICATION_BUNDLE_VERSION, + ZK_STATEMENT_BINDING, + ZK_STATEMENT_ENCODING, + ZK_STATEMENT_DOMAIN, + type VerificationSource, type ProofCapsuleV1, + type ProofBundleLike, } from "../components/KaiVoh/verifierProof"; import { extractProofBundleMetaFromSvg, type ProofBundleMeta } from "../utils/sigilMetadata"; import { derivePhiKeyFromSig } from "../components/VerifierStamper/sigilUtils"; import { tryVerifyGroth16 } from "../components/VerifierStamper/zk"; import { isKASAuthorSig, type KASAuthorSig } from "../utils/authorSig"; -import { isWebAuthnAvailable, verifyBundleAuthorSig } from "../utils/webauthnKAS"; +import { isWebAuthnAvailable, signBundleHash, verifyBundleAuthorSig } from "../utils/webauthnKAS"; import { buildKasChallenge, + ensureReceiverPasskey, getWebAuthnAssertionJson, isReceiveSig, verifyWebAuthnAssertion, type ReceiveSig, } from "../utils/webauthnReceive"; +import { buildOwnerKeyDerivation, deriveOwnerPhiKeyFromReceive, type OwnerKeyDerivation } from "../utils/ownerPhiKey"; import { base64UrlDecode, base64UrlEncode, sha256Hex } from "../utils/sha256"; import { getKaiPulseEternalInt } from "../SovereignSolar"; import { useKaiTicker } from "../hooks/useKaiTicker"; import { useValuation } from "./SigilPage/useValuation"; import type { SigilMetadataLite } from "../utils/valuation"; +import { downloadVerifiedCardPng } from "../og/downloadVerifiedCard"; +import type { VerifiedCardData } from "../og/types"; import { jcsCanonicalize } from "../utils/jcs"; import { svgCanonicalForHash } from "../utils/svgProof"; import useRollingChartSeries from "../components/VerifierStamper/hooks/useRollingChartSeries"; import { BREATH_MS } from "../components/valuation/constants"; +import { + assertReceiptHashMatch, + buildVerificationReceipt, + hashValuationSnapshot, + hashVerificationReceipt, + verificationSigFromKas, + verifyVerificationSig, + type VerificationReceipt, + type VerificationSig, +} from "../utils/verificationReceipt"; +import { buildReceiveBundleRoot, hashReceiveBundleRoot } from "../utils/receiveBundle"; +import { + buildVerificationCacheKey, + buildVerificationCacheRecord, + readVerificationCache, + type VerificationCache, + writeVerificationCache, +} from "../utils/verificationCache"; +import { + buildValuationSnapshotKey, + getOrCreateValuationSnapshot, + type ValuationSnapshotInput, + type ValuationSnapshotState, +} from "../utils/valuationSnapshot"; /* ──────────────────────────────────────────────────────────────── Utilities @@ -85,6 +126,17 @@ type EmbeddedPhiSource = "balance" | "embedded" | "live"; type AttestationState = boolean | "missing"; +type ReceiveBundleState = { + mode?: "origin" | "receive"; + originBundleHash?: string; + receiveBundleHash?: string; + originAuthorSig?: KASAuthorSig | null; + receiveSig?: ReceiveSig | null; + receivePulse?: number; + ownerPhiKey?: string; + ownerKeyDerivation?: OwnerKeyDerivation; +}; + function readLedgerBalance(raw: unknown): { originalAmount: number; remaining: number } | null { if (!isRecord(raw)) return null; const originalAmount = typeof raw.originalAmount === "number" && Number.isFinite(raw.originalAmount) ? raw.originalAmount : null; @@ -163,14 +215,35 @@ type SharedReceipt = { proofCapsule: ProofCapsuleV1; capsuleHash?: string; svgHash?: string; + bundleRoot?: ProofBundleMeta["bundleRoot"]; bundleHash?: string; + mode?: "origin" | "receive"; + originBundleHash?: string; + receiveBundleHash?: string; + originAuthorSig?: ProofBundleMeta["authorSig"]; + receiveSig?: ReceiveSig | null; + receivePulse?: number; + ownerPhiKey?: string; + ownerKeyDerivation?: OwnerKeyDerivation; verifierUrl?: string; shareUrl?: string; + verifier?: VerificationSource; + verificationVersion?: string; + cacheKey?: string; + receipt?: VerificationReceipt; + receiptHash?: string; + verificationSig?: VerificationSig; authorSig?: ProofBundleMeta["authorSig"]; + bindings?: ProofBundleMeta["bindings"]; + zkStatement?: ProofBundleMeta["zkStatement"]; + zkMeta?: ProofBundleMeta["zkMeta"]; + verificationCache?: ProofBundleMeta["verificationCache"]; + transport?: ProofBundleMeta["transport"]; zkPoseidonHash?: string; zkProof?: ProofBundleMeta["zkProof"]; proofHints?: ProofBundleMeta["proofHints"]; zkPublicInputs?: ProofBundleMeta["zkPublicInputs"]; + verifiedAtPulse?: number; }; function parseProofCapsule(raw: unknown): ProofCapsuleV1 | null { @@ -188,18 +261,50 @@ function buildSharedReceiptFromObject(raw: unknown): SharedReceipt | null { if (!isRecord(raw)) return null; const proofCapsule = parseProofCapsule(raw.proofCapsule); if (!proofCapsule) return null; + const verifiedAtPulse = + typeof raw.verifiedAtPulse === "number" && Number.isFinite(raw.verifiedAtPulse) + ? raw.verifiedAtPulse + : typeof raw.verifiedAtPulse === "string" && Number.isFinite(Number(raw.verifiedAtPulse)) + ? Number(raw.verifiedAtPulse) + : undefined; return { proofCapsule, capsuleHash: typeof raw.capsuleHash === "string" ? raw.capsuleHash : undefined, svgHash: typeof raw.svgHash === "string" ? raw.svgHash : undefined, + bundleRoot: isRecord(raw.bundleRoot) ? (raw.bundleRoot as ProofBundleMeta["bundleRoot"]) : undefined, bundleHash: typeof raw.bundleHash === "string" ? raw.bundleHash : undefined, + mode: raw.mode === "receive" || raw.mode === "origin" ? raw.mode : undefined, + originBundleHash: typeof raw.originBundleHash === "string" ? raw.originBundleHash : undefined, + receiveBundleHash: typeof raw.receiveBundleHash === "string" ? raw.receiveBundleHash : undefined, + originAuthorSig: raw.originAuthorSig as ProofBundleMeta["authorSig"], + receiveSig: isReceiveSig(raw.receiveSig) ? raw.receiveSig : null, + receivePulse: + typeof raw.receivePulse === "number" && Number.isFinite(raw.receivePulse) + ? raw.receivePulse + : typeof raw.receivePulse === "string" && Number.isFinite(Number(raw.receivePulse)) + ? Number(raw.receivePulse) + : undefined, + ownerPhiKey: typeof raw.ownerPhiKey === "string" ? raw.ownerPhiKey : undefined, + ownerKeyDerivation: isRecord(raw.ownerKeyDerivation) ? (raw.ownerKeyDerivation as OwnerKeyDerivation) : undefined, verifierUrl: typeof raw.verifierUrl === "string" ? raw.verifierUrl : undefined, + verifier: raw.verifier === "local" || raw.verifier === "pbi" ? (raw.verifier as VerificationSource) : undefined, + verificationVersion: typeof raw.verificationVersion === "string" ? raw.verificationVersion : undefined, shareUrl: typeof raw.shareUrl === "string" ? raw.shareUrl : undefined, + cacheKey: typeof raw.cacheKey === "string" ? raw.cacheKey : undefined, + receipt: isRecord(raw.receipt) ? (raw.receipt as VerificationReceipt) : undefined, + receiptHash: typeof raw.receiptHash === "string" ? raw.receiptHash : undefined, + verificationSig: isRecord(raw.verificationSig) ? (raw.verificationSig as VerificationSig) : undefined, authorSig: raw.authorSig as ProofBundleMeta["authorSig"], + bindings: isRecord(raw.bindings) ? (raw.bindings as ProofBundleMeta["bindings"]) : undefined, + zkStatement: isRecord(raw.zkStatement) ? (raw.zkStatement as ProofBundleMeta["zkStatement"]) : undefined, + zkMeta: isRecord(raw.zkMeta) ? (raw.zkMeta as ProofBundleMeta["zkMeta"]) : undefined, + verificationCache: isRecord(raw.verificationCache) ? (raw.verificationCache as ProofBundleMeta["verificationCache"]) : undefined, + transport: isRecord(raw.transport) ? (raw.transport as ProofBundleMeta["transport"]) : undefined, zkPoseidonHash: typeof raw.zkPoseidonHash === "string" ? raw.zkPoseidonHash : undefined, zkProof: "zkProof" in raw ? raw.zkProof : undefined, proofHints: "proofHints" in raw ? raw.proofHints : undefined, zkPublicInputs: "zkPublicInputs" in raw ? raw.zkPublicInputs : undefined, + verifiedAtPulse, }; } @@ -269,6 +374,14 @@ function bundleHashFromAuthorSig(authorSig: KASAuthorSig): string | null { } } +async function verifyAuthorSigWithFallback(authorSig: KASAuthorSig, bundleHashes: string[]): Promise { + for (const bundleHash of bundleHashes) { + if (!bundleHash) continue; + if (await verifyBundleAuthorSig(bundleHash, authorSig)) return true; + } + return false; +} + function isSvgFile(file: File): boolean { const name = (file.name || "").toLowerCase(); const type = (file.type || "").toLowerCase(); @@ -549,6 +662,7 @@ export default function VerifyPage(): ReactElement { const [proofCapsule, setProofCapsule] = useState(null); const [capsuleHash, setCapsuleHash] = useState(""); const [svgHash, setSvgHash] = useState(""); + const [bundleRoot, setBundleRoot] = useState(null); const [bundleHash, setBundleHash] = useState(""); const [svgBytesHash, setSvgBytesHash] = useState(""); @@ -557,6 +671,8 @@ export default function VerifyPage(): ReactElement { const [authorSigVerified, setAuthorSigVerified] = useState(null); const [receiveSigVerified, setReceiveSigVerified] = useState(null); + const [ownerPhiKeyVerified, setOwnerPhiKeyVerified] = useState(null); + const [ownershipAttested, setOwnershipAttested] = useState("missing"); const [identityAttested, setIdentityAttested] = useState("missing"); const [identityScanRequested, setIdentityScanRequested] = useState(false); const [identityScanBusy, setIdentityScanBusy] = useState(false); @@ -564,8 +680,19 @@ export default function VerifyPage(): ReactElement { const [zkVerify, setZkVerify] = useState(null); const [zkVkey, setZkVkey] = useState(null); + const [zkVerifiedCached, setZkVerifiedCached] = useState(false); + const [verificationCacheEntry, setVerificationCacheEntry] = useState(null); + const [cacheKey, setCacheKey] = useState(""); + const [verificationSig, setVerificationSig] = useState(null); + const [verificationSigVerified, setVerificationSigVerified] = useState(null); + const [verificationSigBusy, setVerificationSigBusy] = useState(false); + const [receiptHash, setReceiptHash] = useState(""); + const [valuationSnapshotState, setValuationSnapshotState] = useState(null); + const [valuationHash, setValuationHash] = useState(""); const [receiveSig, setReceiveSig] = useState(null); + const [localReceiveBundle, setLocalReceiveBundle] = useState(null); + const [receiveBusy, setReceiveBusy] = useState(false); const [dragActive, setDragActive] = useState(false); @@ -648,6 +775,29 @@ export default function VerifyPage(): ReactElement { ? "Glyph embedded value" : "Live glyph valuation"; + const isReceiveGlyph = useMemo(() => { + const mode = localReceiveBundle?.mode ?? embeddedProof?.mode ?? sharedReceipt?.mode; + if (mode === "receive") return true; + if (localReceiveBundle?.receiveSig || embeddedProof?.receiveSig || sharedReceipt?.receiveSig) return true; + if (localReceiveBundle?.originBundleHash || embeddedProof?.originBundleHash || sharedReceipt?.originBundleHash) return true; + if (localReceiveBundle?.ownerPhiKey || embeddedProof?.ownerPhiKey || sharedReceipt?.ownerPhiKey) return true; + return false; + }, [ + embeddedProof?.mode, + embeddedProof?.originBundleHash, + embeddedProof?.ownerPhiKey, + embeddedProof?.receiveSig, + localReceiveBundle?.mode, + localReceiveBundle?.originBundleHash, + localReceiveBundle?.ownerPhiKey, + localReceiveBundle?.receiveSig, + sharedReceipt?.mode, + sharedReceipt?.originBundleHash, + sharedReceipt?.ownerPhiKey, + sharedReceipt?.receiveSig, + ]); + + // Focus Views const [openSvgEditor, setOpenSvgEditor] = useState(false); const [openAuditJson, setOpenAuditJson] = useState(false); @@ -659,6 +809,7 @@ export default function VerifyPage(): ReactElement { const [chartOpen, setChartOpen] = useState(false); const [chartFocus, setChartFocus] = useState<"phi" | "usd">("phi"); const [chartReflowKey, setChartReflowKey] = useState(0); + const chartMode = isReceiveGlyph ? "usd" : chartFocus; // Seal info popovers const [sealPopover, setSealPopover] = useState<"proof" | "kas" | "g16" | null>(null); @@ -721,39 +872,12 @@ export default function VerifyPage(): ReactElement { return () => window.clearTimeout(t); }, [notice]); - React.useEffect(() => { - if (typeof window === "undefined") return; - const statusLabel = result.status === "ok" ? "VERIFIED" : result.status === "error" ? "FAILED" : "STANDBY"; - const origin = window.location.origin; - const slugValue = slug.raw || slugRaw || ""; - const ogUrl = new URL(`${origin}/verify/${encodeURIComponent(slugValue)}`); - const ogImageUrl = new URL(`${origin}/api/og/verify`); - ogImageUrl.searchParams.set("slug", slugValue); - ogImageUrl.searchParams.set("status", statusLabel.toLowerCase()); - if (result.status === "ok") { - ogImageUrl.searchParams.set("pulse", String(result.embedded.pulse ?? slug.pulse ?? "")); - ogImageUrl.searchParams.set("phiKey", result.derivedPhiKey ?? ""); - if (result.embedded.chakraDay) ogImageUrl.searchParams.set("chakraDay", result.embedded.chakraDay); - if (authorSigVerified != null) ogImageUrl.searchParams.set("kas", authorSigVerified ? "1" : "0"); - if (zkVerify != null) ogImageUrl.searchParams.set("g16", zkVerify ? "1" : "0"); - } - - document.title = `Proof of Breath™ — ${statusLabel}`; - ensureMetaTag("property", "og:title", `Proof of Breath™ — ${statusLabel}`); - ensureMetaTag("property", "og:description", `Proof of Breath™ • ${statusLabel} • Pulse ${slug.pulse ?? "—"}`); - ensureMetaTag("property", "og:url", ogUrl.toString()); - ensureMetaTag("property", "og:image", ogImageUrl.toString()); - ensureMetaTag("name", "twitter:card", "summary_large_image"); - ensureMetaTag("name", "twitter:title", `Proof of Breath™ — ${statusLabel}`); - ensureMetaTag("name", "twitter:description", `Proof of Breath™ • ${statusLabel} • Pulse ${slug.pulse ?? "—"}`); - ensureMetaTag("name", "twitter:image", ogImageUrl.toString()); - }, [authorSigVerified, result, slug.pulse, slug.raw, slugRaw, zkVerify]); - const openChartPopover = useCallback((focus: "phi" | "usd") => { - setChartFocus(focus); + const nextFocus = isReceiveGlyph ? "usd" : focus; + setChartFocus(nextFocus); setChartOpen(true); setChartReflowKey((k) => k + 1); - }, []); + }, [isReceiveGlyph]); const closeChartPopover = useCallback(() => { setChartOpen(false); @@ -779,7 +903,7 @@ export default function VerifyPage(): ReactElement { React.useEffect(() => { if (chartOpen) setChartReflowKey((k) => k + 1); - }, [chartOpen, chartFocus]); + }, [chartMode, chartOpen, chartFocus]); React.useEffect(() => { if (!chartOpen) return; @@ -804,6 +928,14 @@ export default function VerifyPage(): ReactElement { return Number.isFinite(candidate) ? candidate : 0; }, [displayPhi, liveValuePhi]); + const chartSeriesValue = useMemo(() => { + if (!isReceiveGlyph) return chartPhi; + const phiStatic = displayPhi ?? liveValuePhi ?? 0; + if (!Number.isFinite(phiStatic)) return 0; + if (!Number.isFinite(usdPerPhi) || usdPerPhi <= 0) return 0; + return phiStatic * usdPerPhi; + }, [chartPhi, displayPhi, isReceiveGlyph, liveValuePhi, usdPerPhi]); + const seriesKey = useMemo(() => { if (result.status === "ok") { return `${result.embedded.pulse ?? slug.pulse ?? "x"}|${result.embedded.kaiSignature ?? ""}|${result.embedded.phiKey ?? ""}`; @@ -814,7 +946,7 @@ export default function VerifyPage(): ReactElement { const chartData = useRollingChartSeries({ seriesKey, sampleMs: BREATH_MS, - valuePhi: chartPhi, + valuePhi: chartSeriesValue, usdPerPhi, maxPoints: 4096, snapKey: chartReflowKey, @@ -823,7 +955,14 @@ export default function VerifyPage(): ReactElement { const pvForChart = useMemo(() => (chartPhi > 0 ? chartPhi : 0), [chartPhi]); const zkMeta = useMemo(() => { - if (embeddedProof) return embeddedProof; + if (embeddedProof) { + return { + zkPoseidonHash: embeddedProof.zkPoseidonHash, + zkProof: embeddedProof.zkProof, + zkPublicInputs: embeddedProof.zkPublicInputs, + proofHints: embeddedProof.transport?.proofHints ?? embeddedProof.proofHints, + } satisfies ProofBundleMeta; + } if (result.status !== "ok") return null; if (!result.embedded.zkProof && !result.embedded.zkPublicInputs && !result.embedded.zkPoseidonHash && !result.embedded.proofHints) return null; @@ -839,7 +978,6 @@ export default function VerifyPage(): ReactElement { const embeddedZkPublicInputs = useMemo(() => (zkMeta?.zkPublicInputs ? formatProofValue(zkMeta.zkPublicInputs) : ""), [zkMeta]); const embeddedProofHints = useMemo(() => (zkMeta?.proofHints ? formatProofValue(zkMeta.proofHints) : ""), [zkMeta]); - const proofVerifierUrl = useMemo(() => (proofCapsule ? buildVerifierUrl(proofCapsule.pulse, proofCapsule.kaiSignature) : ""), [proofCapsule]); const currentVerifyUrl = useMemo(() => { if (typeof window === "undefined") return ""; return window.location.href; @@ -899,15 +1037,29 @@ export default function VerifyPage(): ReactElement { } const receipt = parseSharedReceiptFromText(raw); if (receipt) { - setSharedReceipt(receipt); - setSvgText(""); - setResult({ status: "idle" }); - setNotice("Receipt loaded."); - return; + try { +if (receipt.receiptHash) { + if (!receipt.receipt) { + throw new Error("verification receipt mismatch"); + } + await assertReceiptHashMatch(receipt.receipt, receipt.receiptHash); +} + + setSharedReceipt(receipt); + setSvgText(""); + setResult({ status: "idle" }); + setNotice("Receipt loaded."); + return; + } catch (err) { + const msg = err instanceof Error ? err.message : "verification receipt mismatch"; + setResult({ status: "error", message: msg, slug }); + return; + } } setBusy(true); try { - const next = await verifySigilSvg(slug, raw); + const verifiedAtPulse = currentPulse ?? getKaiPulseEternalInt(new Date()); + const next = await verifySigilSvg(slug, raw, verifiedAtPulse); setResult(next); if (next.status === "ok") { setIdentityAttested("missing"); @@ -918,7 +1070,7 @@ export default function VerifyPage(): ReactElement { } finally { setBusy(false); } - }, [slug, svgText]); + }, [currentPulse, slug, svgText]); // Proof bundle construction (logic unchanged) React.useEffect(() => { @@ -929,9 +1081,14 @@ export default function VerifyPage(): ReactElement { setProofCapsule(null); setCapsuleHash(""); setSvgHash(""); + setBundleRoot(null); setBundleHash(""); setEmbeddedProof(null); setAuthorSigVerified(null); + setVerificationCacheEntry(null); + setZkVerifiedCached(false); + setVerificationSig(null); + setReceiptHash(""); setNotice(""); return; } @@ -948,6 +1105,13 @@ export default function VerifyPage(): ReactElement { bundleHash: sharedReceipt.bundleHash, shareUrl: sharedReceipt.shareUrl, verifierUrl: sharedReceipt.verifierUrl, + verifier: sharedReceipt.verifier, + verificationVersion: sharedReceipt.verificationVersion, + cacheKey: sharedReceipt.cacheKey, + receipt: sharedReceipt.receipt, + receiptHash: sharedReceipt.receiptHash, + verificationSig: sharedReceipt.verificationSig, + verifiedAtPulse: sharedReceipt.verifiedAtPulse, authorSig: sharedReceipt.authorSig, zkPoseidonHash: sharedReceipt.zkPoseidonHash, zkProof: sharedReceipt.zkProof, @@ -970,44 +1134,109 @@ export default function VerifyPage(): ReactElement { const embedded = extractProofBundleMetaFromSvg(svgText); const capsule = embedded?.proofCapsule ?? fallbackCapsule; const capsuleHashNext = await hashProofCapsuleV1(capsule); + const embeddedVerifiedAtPulse = + typeof embedded?.verifiedAtPulse === "number" && Number.isFinite(embedded.verifiedAtPulse) + ? embedded.verifiedAtPulse + : undefined; + const verifiedAtPulse = + embeddedVerifiedAtPulse ?? + (embedded + ? undefined + : typeof result.verifiedAtPulse === "number" && Number.isFinite(result.verifiedAtPulse) + ? result.verifiedAtPulse + : undefined); + + const verifierValue = embedded?.verifier; + const verificationVersionValue = embedded?.verificationVersion; + const proofHintsValue = embedded?.transport?.proofHints ?? embedded?.proofHints; + const embeddedMode = embedded?.mode; + const embeddedOriginSig = embedded?.originAuthorSig; const bundleSeed = embedded?.raw && typeof embedded.raw === "object" && embedded.raw !== null - ? { ...(embedded.raw as Record), svgHash: svgHashNext, capsuleHash: capsuleHashNext, proofCapsule: capsule } + ? { + ...(embedded.raw as Record), + svgHash: svgHashNext, + capsuleHash: capsuleHashNext, + proofCapsule: capsule, + ...(verifierValue ? { verifier: verifierValue } : {}), + ...(verificationVersionValue ? { verificationVersion: verificationVersionValue } : {}), + ...(verifiedAtPulse != null ? { verifiedAtPulse } : {}), + } : { hashAlg: embedded?.hashAlg ?? PROOF_HASH_ALG, canon: embedded?.canon ?? PROOF_CANON, + bindings: embedded?.bindings ?? PROOF_BINDINGS, + zkStatement: embedded?.zkStatement, + bundleRoot: embedded?.bundleRoot, + zkMeta: embedded?.zkMeta, proofCapsule: capsule, capsuleHash: capsuleHashNext, svgHash: svgHashNext, shareUrl: embedded?.shareUrl, verifierUrl: embedded?.verifierUrl, + ...(verifierValue ? { verifier: verifierValue } : {}), + ...(verificationVersionValue ? { verificationVersion: verificationVersionValue } : {}), + ...(verifiedAtPulse != null ? { verifiedAtPulse } : {}), zkPoseidonHash: embedded?.zkPoseidonHash, zkProof: embedded?.zkProof, - proofHints: embedded?.proofHints, + proofHints: proofHintsValue, zkPublicInputs: embedded?.zkPublicInputs, - authorSig: embedded?.authorSig ?? null, + mode: embeddedMode, + originBundleHash: embedded?.originBundleHash, + receiveBundleHash: embedded?.receiveBundleHash, + originAuthorSig: embeddedOriginSig ?? null, + receiveSig: embedded?.receiveSig ?? null, + receivePulse: embedded?.receivePulse, + ownerPhiKey: embedded?.ownerPhiKey, + ownerKeyDerivation: embedded?.ownerKeyDerivation, + authorSig: embeddedMode === "receive" || embeddedOriginSig ? null : embedded?.authorSig ?? null, }; - const bundleUnsigned = buildBundleUnsigned(bundleSeed); - const bundleHashNext = await hashBundle(bundleUnsigned); - -const authorSigNext = embedded?.authorSig; -let authorSigOk: boolean | null = null; - -if (authorSigNext) { - if (isKASAuthorSig(authorSigNext)) { - // ✅ Verify KAS against the artifact's recomputed unsigned bundle hash - authorSigOk = await verifyBundleAuthorSig(bundleHashNext, authorSigNext); - } else { - authorSigOk = false; - } -} + const bundleRootNext = buildBundleRoot(bundleSeed); + const rootHash = await computeBundleHash(bundleRootNext); + const legacySeed = { ...bundleSeed } as Record; + delete legacySeed.bundleRoot; + delete legacySeed.transport; + delete legacySeed.verificationCache; + delete legacySeed.cacheKey; + delete legacySeed.receipt; + delete legacySeed.receiptHash; + delete legacySeed.verificationSig; + delete legacySeed.zkMeta; + const bundleUnsigned = buildBundleUnsigned(legacySeed); + const legacyHash = await hashBundle(bundleUnsigned); + const useRootHash = + Boolean(embedded?.bundleRoot) || + embedded?.bindings?.bundleHashOf === PROOF_BINDINGS.bundleHashOf; + const bundleHashNext = useRootHash ? rootHash : legacyHash; + + const authorSigNext = embeddedOriginSig ?? embedded?.authorSig; + let authorSigOk: boolean | null = null; + + if (embeddedOriginSig) { + if (!embedded?.originBundleHash || !isKASAuthorSig(embeddedOriginSig)) { + authorSigOk = false; + } else { + authorSigOk = await verifyBundleAuthorSig(embedded.originBundleHash, embeddedOriginSig); + } + } else if (authorSigNext) { + if (isKASAuthorSig(authorSigNext)) { + const authorSigBundleHash = bundleHashFromAuthorSig(authorSigNext); + const candidateHashes = Array.from( + new Set([authorSigBundleHash, bundleHashNext, rootHash, legacyHash].filter(Boolean)) + ) as string[]; + authorSigOk = await verifyAuthorSigWithFallback(authorSigNext, candidateHashes); + } else { + authorSigOk = false; + } + } if (!active) return; setProofCapsule(capsule); setSvgHash(svgHashNext); setCapsuleHash(capsuleHashNext); + setBundleRoot(useRootHash ? bundleRootNext : embedded?.bundleRoot ?? null); setBundleHash(bundleHashNext); setEmbeddedProof(embedded); setAuthorSigVerified(authorSigOk); @@ -1027,10 +1256,31 @@ if (authorSigNext) { proofCapsule: capsule, svgHash: sharedReceipt.svgHash, capsuleHash: sharedReceipt.capsuleHash, + bundleRoot: sharedReceipt.bundleRoot, bundleHash: sharedReceipt.bundleHash, shareUrl: sharedReceipt.shareUrl, verifierUrl: sharedReceipt.verifierUrl, + verifier: sharedReceipt.verifier, + verificationVersion: sharedReceipt.verificationVersion, + cacheKey: sharedReceipt.cacheKey, + receipt: sharedReceipt.receipt, + receiptHash: sharedReceipt.receiptHash, + verificationSig: sharedReceipt.verificationSig, + verifiedAtPulse: sharedReceipt.verifiedAtPulse, authorSig: sharedReceipt.authorSig, + mode: sharedReceipt.mode, + originBundleHash: sharedReceipt.originBundleHash, + receiveBundleHash: sharedReceipt.receiveBundleHash, + originAuthorSig: sharedReceipt.originAuthorSig ?? null, + receiveSig: sharedReceipt.receiveSig ?? null, + receivePulse: sharedReceipt.receivePulse, + ownerPhiKey: sharedReceipt.ownerPhiKey, + ownerKeyDerivation: sharedReceipt.ownerKeyDerivation, + bindings: sharedReceipt.bindings, + zkStatement: sharedReceipt.zkStatement, + zkMeta: sharedReceipt.zkMeta, + verificationCache: sharedReceipt.verificationCache, + transport: sharedReceipt.transport, zkPoseidonHash: sharedReceipt.zkPoseidonHash, zkProof: sharedReceipt.zkProof, proofHints: sharedReceipt.proofHints, @@ -1043,6 +1293,7 @@ if (authorSigNext) { const slugShortSigMatches = slug.shortSig == null ? null : slug.shortSig === capsule.kaiSignature.slice(0, slug.shortSig.length); const derivedPhiKeyMatchesEmbedded = capsule.phiKey ? derivedPhiKey === capsule.phiKey : null; + const verifiedAtPulse = sharedReceipt.verifiedAtPulse ?? null; if (!active) return; const checks = { @@ -1079,12 +1330,14 @@ if (authorSigNext) { embedded: baseEmbedded, derivedPhiKey, checks, + verifiedAtPulse, }, ); setEmbeddedProof(embed); setProofCapsule(capsule); setCapsuleHash(sharedReceipt.capsuleHash ?? ""); setSvgHash(sharedReceipt.svgHash ?? ""); + setBundleRoot(sharedReceipt.bundleRoot ?? null); setBundleHash(sharedReceipt.bundleHash ?? ""); })(); @@ -1092,14 +1345,45 @@ if (authorSigNext) { active = false; }; }, [sharedReceipt, slug, svgText]); +React.useEffect(() => { + let active = true; + + const rh = sharedReceipt?.receiptHash; + if (typeof rh !== "string" || rh.trim().length === 0) return; + + (async () => { + try { + if (!sharedReceipt?.receipt) { + throw new Error("verification receipt mismatch"); + } + await assertReceiptHashMatch(sharedReceipt.receipt, rh); + } catch (err) { + if (!active) return; + const msg = err instanceof Error ? err.message : "verification receipt mismatch"; + setResult({ status: "error", message: msg, slug }); + setSharedReceipt(null); + } + })(); + + return () => { + active = false; + }; +}, [sharedReceipt, slug]); + + React.useEffect(() => { + const nextSig = embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig ?? null; + setVerificationSig(nextSig); + }, [embeddedProof?.verificationSig, sharedReceipt?.verificationSig, bundleHash]); React.useEffect(() => { if (result.status !== "ok" || !bundleHash) { setReceiveSig(null); setReceiveSigVerified(null); + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); return; } - const embeddedReceive = readReceiveSigFromBundle(embeddedProof?.raw ?? result.embedded.raw); + const embeddedReceive = embeddedProof?.receiveSig ?? readReceiveSigFromBundle(embeddedProof?.raw ?? result.embedded.raw); if (embeddedReceive) { setReceiveSig(embeddedReceive); return; @@ -1107,56 +1391,29 @@ if (authorSigNext) { setReceiveSig(null); setReceiveSigVerified(null); + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); }, [result.status, bundleHash, embeddedProof?.raw]); React.useEffect(() => { - let active = true; - if (!receiveSig || !bundleHash) { - setReceiveSigVerified(null); - return; - } - - (async () => { - const receiveBundleHash = receiveSig.binds.bundleHash; - if (!receiveBundleHash) { - if (active) setReceiveSigVerified(false); - return; - } - const { challengeBytes } = await buildKasChallenge("receive", receiveBundleHash, receiveSig.nonce); - const ok = await verifyWebAuthnAssertion({ - assertion: receiveSig.assertion, - expectedChallenge: challengeBytes, - pubKeyJwk: receiveSig.pubKeyJwk, - expectedCredId: receiveSig.credId, - }); - if (active) setReceiveSigVerified(ok); - })(); + setLocalReceiveBundle(null); + }, [bundleHash, svgText]); - return () => { - active = false; - }; - }, [receiveSig, bundleHash]); React.useEffect(() => { let active = true; - if (!embeddedProof?.authorSig || !bundleHash) return; - if (authorSigVerified !== null) return; - const authorSigNext = embeddedProof.authorSig; - + if (!receiptHash || !verificationSig) { + setVerificationSigVerified(null); + return; + } (async () => { - if (!isKASAuthorSig(authorSigNext)) { - if (active) setAuthorSigVerified(false); - return; - } - const authorBundleHash = bundleHashFromAuthorSig(authorSigNext); - const ok = await verifyBundleAuthorSig(authorBundleHash ?? bundleHash, authorSigNext); - if (active) setAuthorSigVerified(ok); + const ok = await verifyVerificationSig(receiptHash, verificationSig); + if (active) setVerificationSigVerified(ok); })(); - return () => { active = false; }; - }, [authorSigVerified, bundleHash, embeddedProof?.authorSig]); + }, [receiptHash, verificationSig]); React.useEffect(() => { if (!svgText.trim()) { @@ -1180,15 +1437,93 @@ if (authorSigNext) { setArtifactAttested(svgBytesHash === expectedSvgHash); }, [embeddedProof?.svgHash, sharedReceipt?.svgHash, svgBytesHash, svgText]); + const stewardVerifiedPulse = useMemo(() => { + if (result.status === "ok") return result.verifiedAtPulse; + return sharedReceipt?.verifiedAtPulse ?? null; + }, [result, sharedReceipt?.verifiedAtPulse]); + + const valuationSnapshotKey = useMemo(() => { + if (!bundleHash || stewardVerifiedPulse == null) return ""; + return buildValuationSnapshotKey(bundleHash, stewardVerifiedPulse); + }, [bundleHash, stewardVerifiedPulse]); + + const valuationSnapshotInput = useMemo(() => { + if (result.status !== "ok" || stewardVerifiedPulse == null) return null; + if (displayPhi == null || !Number.isFinite(displayPhi)) return null; + const usdPerPhiValue = Number.isFinite(usdPerPhi) && usdPerPhi > 0 ? usdPerPhi : null; + const mode: ValuationSnapshotInput["mode"] = isReceiveGlyph ? "receive" : "origin"; + return { + verifiedAtPulse: stewardVerifiedPulse, + phiValue: displayPhi, + usdPerPhi: usdPerPhiValue, + source: displaySource ?? "unknown", + mode, + } satisfies ValuationSnapshotInput; + }, [displayPhi, displaySource, isReceiveGlyph, result.status, stewardVerifiedPulse, usdPerPhi]); + + React.useEffect(() => { + setValuationSnapshotState((prev) => { + if (!valuationSnapshotKey) return null; + if (prev?.key === valuationSnapshotKey) return prev; + return getOrCreateValuationSnapshot(prev, valuationSnapshotKey, valuationSnapshotInput); + }); + }, [valuationSnapshotInput, valuationSnapshotKey]); + + const valuationSnapshot = useMemo(() => valuationSnapshotState?.snapshot ?? null, [valuationSnapshotState]); + + React.useEffect(() => { + let active = true; + if (!valuationSnapshot) { + setValuationHash(""); + return; + } + (async () => { + const hash = await hashValuationSnapshot(valuationSnapshot); + if (active) setValuationHash(hash); + })(); + return () => { + active = false; + }; + }, [valuationSnapshot]); + + const verificationSource: VerificationSource = sharedReceipt?.verifier ?? "local"; + const verificationVersion = sharedReceipt?.verificationVersion ?? VERIFICATION_BUNDLE_VERSION; + const cacheVerificationVersion = sharedReceipt?.verificationVersion ?? embeddedProof?.verificationVersion; + // Groth16 verify (logic unchanged) React.useEffect(() => { let active = true; (async () => { if (!zkMeta?.zkProof || !zkMeta?.zkPublicInputs) { - if (active) setZkVerify(null); + if (active) { + setZkVerify(null); + setZkVerifiedCached(false); + setVerificationCacheEntry(null); + } return; } +const cacheBundleHash = bundleHash; +const cachePoseidonHash = + typeof zkMeta?.zkPoseidonHash === "string" && zkMeta.zkPoseidonHash.trim().length > 0 + ? zkMeta.zkPoseidonHash + : undefined; + +if (typeof cacheBundleHash === "string" && cacheBundleHash.trim().length > 0 && cachePoseidonHash) { + const cached = await readVerificationCache({ + bundleHash: cacheBundleHash, + zkPoseidonHash: cachePoseidonHash, + verificationVersion: cacheVerificationVersion, + }); + + if (cached && active) { + setZkVerify(true); + setZkVerifiedCached(true); + setVerificationCacheEntry({ ...cached, zkVerifiedCached: true }); + return; + } +} + if (!zkVkey) { try { @@ -1203,25 +1538,93 @@ if (authorSigNext) { } } + const expectedVkHash = embeddedProof?.zkMeta?.vkHash; + if (expectedVkHash && zkVkey && typeof zkVkey === "object") { + try { + const vkeyCanonical = jcsCanonicalize(zkVkey as Parameters[0]); + const vkeyHash = await sha256Hex(vkeyCanonical); + if (vkeyHash !== expectedVkHash) { + if (active) { + setZkVerify(false); + setZkVerifiedCached(false); + } + return; + } + } catch { + if (active) { + setZkVerify(false); + setZkVerifiedCached(false); + } + return; + } + } + const parsedProof = parseJsonString(zkMeta.zkProof); const parsedInputs = parseJsonString(zkMeta.zkPublicInputs); - const inputs = Array.isArray(parsedInputs) || typeof parsedInputs === "object" ? parsedInputs : [parsedInputs]; + const inputsArray = Array.isArray(parsedInputs) + ? parsedInputs.map((entry) => String(entry)) + : parsedInputs && typeof parsedInputs === "object" + ? Object.values(parsedInputs as Record).map((entry) => String(entry)) + : [String(parsedInputs)]; + try { + assertZkCurveConsistency({ + zkProof: parsedProof, + zkMeta: embeddedProof?.zkMeta ?? embeddedProof?.bundleRoot?.zkMeta, + }); + assertZkPublicInputsContract({ + zkPublicInputs: inputsArray, + zkPoseidonHash: zkMeta?.zkPoseidonHash, + }); + } catch { + if (active) { + setZkVerify(false); + setZkVerifiedCached(false); + } + return; + } const verified = await tryVerifyGroth16({ proof: parsedProof, - publicSignals: inputs, + publicSignals: inputsArray, vkey: zkVkey ?? undefined, fallbackVkey: zkVkey ?? undefined, }); if (!active) return; setZkVerify(verified); + setZkVerifiedCached(false); +if (verified && typeof cacheBundleHash === "string" && cacheBundleHash.trim().length > 0 && cachePoseidonHash) { + const entry = await buildVerificationCacheRecord({ + bundleHash: cacheBundleHash, + zkPoseidonHash: cachePoseidonHash, + verificationVersion: cacheVerificationVersion, + verifiedAtPulse: stewardVerifiedPulse ?? undefined, + verifier: verificationSource, + createdAtMs: Date.now(), + expiresAtPulse: null, + }); + + await writeVerificationCache(entry); + if (active) setVerificationCacheEntry(entry); +} else { + setVerificationCacheEntry(null); +} + })(); return () => { active = false; }; - }, [zkMeta, zkVkey]); + }, [ + bundleHash, + cacheVerificationVersion, + embeddedProof?.bundleRoot?.zkMeta, + embeddedProof?.zkMeta, + verificationSource, + stewardVerifiedPulse, + zkMeta, + zkVkey, + ]); const attemptIdentityScan = useCallback( async (authorSig: KASAuthorSig, bundleHashValue: string): Promise => { @@ -1265,6 +1668,312 @@ if (authorSigNext) { }, [identityScanBusy] ); + const verificationReceipt = useMemo(() => { + if (!bundleHash || !zkMeta?.zkPoseidonHash || stewardVerifiedPulse == null) return null; + const valuationPayload = valuationSnapshot && valuationHash ? { valuation: valuationSnapshot, valuationHash } : undefined; + return buildVerificationReceipt({ + bundleHash, + zkPoseidonHash: zkMeta.zkPoseidonHash, + verifiedAtPulse: stewardVerifiedPulse, + verifier: verificationSource, + verificationVersion, + ...(valuationPayload ?? {}), + }); + }, [bundleHash, stewardVerifiedPulse, valuationHash, valuationSnapshot, verificationSource, verificationVersion, zkMeta?.zkPoseidonHash]); + + const effectiveReceiveSig = useMemo(() => localReceiveBundle?.receiveSig ?? receiveSig ?? null, [localReceiveBundle?.receiveSig, receiveSig]); + const effectiveReceivePulse = useMemo(() => { + if (localReceiveBundle?.receivePulse != null) return localReceiveBundle.receivePulse; + if (embeddedProof?.receivePulse != null) return embeddedProof.receivePulse; + if (sharedReceipt?.receivePulse != null) return sharedReceipt.receivePulse; + return effectiveReceiveSig?.createdAtPulse ?? null; + }, [embeddedProof?.receivePulse, localReceiveBundle?.receivePulse, sharedReceipt?.receivePulse, effectiveReceiveSig?.createdAtPulse]); + const effectiveReceiveBundleHash = useMemo(() => { + if (localReceiveBundle?.receiveBundleHash) return localReceiveBundle.receiveBundleHash; + if (embeddedProof?.receiveBundleHash) return embeddedProof.receiveBundleHash; + if (sharedReceipt?.receiveBundleHash) return sharedReceipt.receiveBundleHash; + if (effectiveReceiveSig?.binds.bundleHash) return effectiveReceiveSig.binds.bundleHash; + return ""; + }, [embeddedProof?.receiveBundleHash, effectiveReceiveSig?.binds.bundleHash, localReceiveBundle?.receiveBundleHash, sharedReceipt?.receiveBundleHash]); + const effectiveReceiveMode = useMemo(() => { + if (localReceiveBundle?.mode) return localReceiveBundle.mode; + if (embeddedProof?.mode) return embeddedProof.mode; + if (sharedReceipt?.mode) return sharedReceipt.mode; + return effectiveReceiveSig ? "receive" : undefined; + }, [embeddedProof?.mode, effectiveReceiveSig, localReceiveBundle?.mode, sharedReceipt?.mode]); + + React.useEffect(() => { + if (typeof window === "undefined") return; + const statusLabel = result.status === "ok" ? "VERIFIED" : result.status === "error" ? "FAILED" : "STANDBY"; + const origin = window.location.origin; + const slugValue = slug.raw || slugRaw || ""; + const ogUrl = new URL(`${origin}/verify/${encodeURIComponent(slugValue)}`); + const ogImageUrl = new URL(`${origin}/api/og/verify`); + ogImageUrl.searchParams.set("slug", slugValue); + ogImageUrl.searchParams.set("status", statusLabel.toLowerCase()); + if (result.status === "ok") { + ogImageUrl.searchParams.set("pulse", String(result.embedded.pulse ?? slug.pulse ?? "")); + const ogPhiKey = + localReceiveBundle?.ownerPhiKey ?? + embeddedProof?.ownerPhiKey ?? + sharedReceipt?.ownerPhiKey ?? + result.derivedPhiKey ?? + ""; + ogImageUrl.searchParams.set("phiKey", ogPhiKey); + if (result.embedded.chakraDay) ogImageUrl.searchParams.set("chakraDay", result.embedded.chakraDay); + const kasStatus = effectiveReceiveSig ? receiveSigVerified : authorSigVerified; + if (kasStatus != null) ogImageUrl.searchParams.set("kas", kasStatus ? "1" : "0"); + if (zkVerify != null) ogImageUrl.searchParams.set("g16", zkVerify ? "1" : "0"); + } + + document.title = `Proof of Breath™ — ${statusLabel}`; + ensureMetaTag("property", "og:title", `Proof of Breath™ — ${statusLabel}`); + ensureMetaTag("property", "og:description", `Proof of Breath™ • ${statusLabel} • Pulse ${slug.pulse ?? "—"}`); + ensureMetaTag("property", "og:url", ogUrl.toString()); + ensureMetaTag("property", "og:image", ogImageUrl.toString()); + ensureMetaTag("name", "twitter:card", "summary_large_image"); + ensureMetaTag("name", "twitter:title", `Proof of Breath™ — ${statusLabel}`); + ensureMetaTag("name", "twitter:description", `Proof of Breath™ • ${statusLabel} • Pulse ${slug.pulse ?? "—"}`); + ensureMetaTag("name", "twitter:image", ogImageUrl.toString()); + }, [ + authorSigVerified, + effectiveReceiveSig, + embeddedProof?.ownerPhiKey, + localReceiveBundle?.ownerPhiKey, + receiveSigVerified, + result, + sharedReceipt?.ownerPhiKey, + slug.pulse, + slug.raw, + slugRaw, + zkVerify, + ]); + const effectiveOriginBundleHash = useMemo( + () => + localReceiveBundle?.originBundleHash ?? + embeddedProof?.originBundleHash ?? + sharedReceipt?.originBundleHash ?? + undefined, + [embeddedProof?.originBundleHash, localReceiveBundle?.originBundleHash, sharedReceipt?.originBundleHash], + ); + const effectiveOriginAuthorSig = useMemo( + () => + localReceiveBundle?.originAuthorSig ?? + embeddedProof?.originAuthorSig ?? + sharedReceipt?.originAuthorSig ?? + null, + [embeddedProof?.originAuthorSig, localReceiveBundle?.originAuthorSig, sharedReceipt?.originAuthorSig], + ); + const effectiveOwnerPhiKey = useMemo( + () => + localReceiveBundle?.ownerPhiKey ?? + embeddedProof?.ownerPhiKey ?? + sharedReceipt?.ownerPhiKey ?? + undefined, + [embeddedProof?.ownerPhiKey, localReceiveBundle?.ownerPhiKey, sharedReceipt?.ownerPhiKey], + ); + const effectiveOwnerKeyDerivation = useMemo( + () => + localReceiveBundle?.ownerKeyDerivation ?? + embeddedProof?.ownerKeyDerivation ?? + sharedReceipt?.ownerKeyDerivation ?? + undefined, + [embeddedProof?.ownerKeyDerivation, localReceiveBundle?.ownerKeyDerivation, sharedReceipt?.ownerKeyDerivation], + ); + + const receiveBundleRoot = useMemo(() => { + if (!proofCapsule || !capsuleHash || !svgHash) return null; + const bundleSeed: ProofBundleLike = { + hashAlg: embeddedProof?.hashAlg ?? PROOF_HASH_ALG, + canon: embeddedProof?.canon ?? PROOF_CANON, + bindings: embeddedProof?.bindings ?? PROOF_BINDINGS, + zkStatement: embeddedProof?.zkStatement, + bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot, + zkMeta: embeddedProof?.zkMeta, + proofCapsule, + capsuleHash, + svgHash, + zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, + zkProof: zkMeta?.zkProof ?? undefined, + zkPublicInputs: zkMeta?.zkPublicInputs ?? undefined, + }; + return buildReceiveBundleRoot({ + bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot ?? undefined, + bundle: bundleSeed, + originBundleHash: effectiveOriginBundleHash ?? bundleHash ?? undefined, + originAuthorSig: effectiveOriginAuthorSig ?? null, + receivePulse: effectiveReceivePulse ?? undefined, + }); + }, [ + bundleHash, + bundleRoot, + capsuleHash, + embeddedProof?.bindings, + embeddedProof?.bundleRoot, + embeddedProof?.canon, + embeddedProof?.hashAlg, + embeddedProof?.zkMeta, + embeddedProof?.zkStatement, + effectiveOriginAuthorSig, + effectiveOriginBundleHash, + effectiveReceivePulse, + proofCapsule, + svgHash, + zkMeta?.zkPoseidonHash, + zkMeta?.zkProof, + zkMeta?.zkPublicInputs, + ]); + + React.useEffect(() => { + let active = true; + const receiveMode = effectiveReceiveMode === "receive"; + + if (!receiveMode && !effectiveReceiveSig) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + if (!effectiveReceiveSig) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + const receiveBundleHashValue = effectiveReceiveBundleHash || effectiveReceiveSig.binds.bundleHash; + if (!receiveBundleHashValue) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } + + if (effectiveReceiveSig.binds.bundleHash !== receiveBundleHashValue) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } + + if (effectiveReceivePulse == null) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } + + if ( + typeof effectiveReceiveSig.createdAtPulse === "number" && + Number.isFinite(effectiveReceiveSig.createdAtPulse) && + effectiveReceiveSig.createdAtPulse !== effectiveReceivePulse + ) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } + + if (receiveSigVerified === null) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + if (receiveSigVerified === false) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + return; + } + + if (!receiveBundleRoot) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + if (!effectiveOwnerPhiKey) { + setOwnerPhiKeyVerified(null); + setOwnershipAttested("missing"); + return; + } + + (async () => { + const expectedReceiveBundleHash = await hashReceiveBundleRoot(receiveBundleRoot); + if (expectedReceiveBundleHash !== receiveBundleHashValue) { + if (active) { + setOwnerPhiKeyVerified(false); + setOwnershipAttested(false); + } + return; + } + const expectedOwnerPhiKey = await deriveOwnerPhiKeyFromReceive({ + receiverPubKeyJwk: effectiveReceiveSig.pubKeyJwk, + receivePulse: effectiveReceivePulse, + receiveBundleHash: receiveBundleHashValue, + }); + const ok = expectedOwnerPhiKey === effectiveOwnerPhiKey; + if (active) { + setOwnerPhiKeyVerified(ok); + setOwnershipAttested(ok); + } + })(); + + return () => { + active = false; + }; + }, [ + effectiveOwnerPhiKey, + effectiveReceiveBundleHash, + effectiveReceiveMode, + effectiveReceivePulse, + effectiveReceiveSig, + receiveBundleRoot, + receiveSigVerified, + ]); + + React.useEffect(() => { + let active = true; + if (!bundleHash) return; + const originSig = effectiveOriginAuthorSig; + const legacySig = embeddedProof?.authorSig; + + if (!originSig && !legacySig) { + setAuthorSigVerified(null); + return; + } + + (async () => { + if (originSig) { + if (!isKASAuthorSig(originSig)) { + if (active) setAuthorSigVerified(false); + return; + } + const derivedHash = bundleHashFromAuthorSig(originSig); + const originBundleHash = effectiveOriginBundleHash ?? (derivedHash && derivedHash === bundleHash ? derivedHash : null); + const candidateHashes = originBundleHash ? [originBundleHash] : []; + if (!candidateHashes.length) { + if (active) setAuthorSigVerified(false); + return; + } + const ok = await verifyAuthorSigWithFallback(originSig, candidateHashes); + if (active) setAuthorSigVerified(ok); + return; + } + + if (!legacySig || !isKASAuthorSig(legacySig)) { + if (active) setAuthorSigVerified(false); + return; + } + const authorBundleHash = bundleHashFromAuthorSig(legacySig); + const candidateHashes = Array.from(new Set([authorBundleHash, bundleHash].filter(Boolean))) as string[]; + const ok = await verifyAuthorSigWithFallback(legacySig, candidateHashes); + if (active) setAuthorSigVerified(ok); + })(); + + return () => { + active = false; + }; + }, [bundleHash, embeddedProof?.authorSig, effectiveOriginAuthorSig, effectiveOriginBundleHash]); + + const receiveCredId = useMemo(() => (effectiveReceiveSig ? effectiveReceiveSig.credId : ""), [effectiveReceiveSig]); + const receiveNonce = useMemo(() => (effectiveReceiveSig?.nonce ? effectiveReceiveSig.nonce : ""), [effectiveReceiveSig?.nonce]); + const receiveBundleHash = useMemo(() => effectiveReceiveBundleHash, [effectiveReceiveBundleHash]); React.useEffect(() => { if (!identityScanRequested) return; @@ -1272,7 +1981,7 @@ if (authorSigNext) { setIdentityScanRequested(false); return; } - const authorSig = embeddedProof?.authorSig; + const authorSig = effectiveOriginAuthorSig ?? embeddedProof?.authorSig; if (!authorSig || !isKASAuthorSig(authorSig)) { setIdentityAttested("missing"); setIdentityScanRequested(false); @@ -1280,7 +1989,7 @@ if (authorSigNext) { } if (!bundleHash) return; void attemptIdentityScan(authorSig, bundleHash); - }, [attemptIdentityScan, bundleHash, embeddedProof?.authorSig, identityScanRequested, svgText]); + }, [attemptIdentityScan, bundleHash, embeddedProof?.authorSig, effectiveOriginAuthorSig, identityScanRequested, svgText]); const badge: { kind: BadgeKind; title: string; subtitle?: string } = useMemo(() => { if (busy) return { kind: "busy", title: "SEALING", subtitle: "Deterministic proof rails executing." }; @@ -1293,14 +2002,27 @@ if (authorSigNext) { () => (result.status === "ok" ? String(result.embedded.pulse ?? (slug.pulse ?? 0)) : String(slug.pulse ?? 0)), [result, slug.pulse], ); - const kpiPhiKey = useMemo(() => (result.status === "ok" ? result.derivedPhiKey || "—" : "—"), [result]); + const effectivePhiKey = useMemo(() => { + if (effectiveOwnerPhiKey) return effectiveOwnerPhiKey; + return result.status === "ok" ? result.derivedPhiKey || "—" : "—"; + }, [effectiveOwnerPhiKey, result]); + const kpiPhiKey = useMemo(() => effectivePhiKey, [effectivePhiKey]); + + const provenanceSig = useMemo( + () => effectiveOriginAuthorSig ?? embeddedProof?.authorSig ?? null, + [embeddedProof?.authorSig, effectiveOriginAuthorSig], + ); const sealKAS: SealState = useMemo(() => { if (busy) return "busy"; - if (!embeddedProof?.authorSig) return "off"; + if (effectiveReceiveSig) { + if (receiveSigVerified === null) return "na"; + return receiveSigVerified ? "valid" : "invalid"; + } + if (!provenanceSig) return "off"; if (authorSigVerified === null) return "na"; return authorSigVerified ? "valid" : "invalid"; - }, [busy, embeddedProof?.authorSig, authorSigVerified]); + }, [authorSigVerified, busy, effectiveReceiveSig, provenanceSig, receiveSigVerified]); const sealZK: SealState = useMemo(() => { if (busy) return "busy"; @@ -1309,6 +2031,225 @@ if (authorSigNext) { return zkVerify ? "valid" : "invalid"; }, [busy, zkMeta?.zkPoseidonHash, zkVerify]); + const proofVerifierUrl = useMemo( + () => (proofCapsule ? buildVerifierUrl(proofCapsule.pulse, proofCapsule.kaiSignature, undefined, stewardVerifiedPulse ?? undefined) : ""), + [proofCapsule, stewardVerifiedPulse], + ); + + const proofBindings = useMemo(() => embeddedProof?.bindings ?? PROOF_BINDINGS, [embeddedProof?.bindings]); + const zkStatementValue = useMemo(() => { + if (embeddedProof?.zkStatement) return embeddedProof.zkStatement; + if (zkMeta?.zkPoseidonHash) { + return { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT, + encoding: ZK_STATEMENT_ENCODING, + }; + } + return null; + }, [embeddedProof?.zkStatement, zkMeta?.zkPoseidonHash]); + const publicInputsContractLabel = useMemo(() => { + if (!zkStatementValue?.publicInputsContract) return "—"; + return `${zkStatementValue.publicInputsContract.arity} • ${zkStatementValue.publicInputsContract.invariant}`; + }, [zkStatementValue?.publicInputsContract]); + + const verifiedCardData = useMemo(() => { + if (result.status !== "ok" || !proofCapsule || !capsuleHash || stewardVerifiedPulse == null) return null; + const receiptValue = verificationReceipt ?? embeddedProof?.receipt ?? sharedReceipt?.receipt; + const receiptHashValue = receiptHash || embeddedProof?.receiptHash || sharedReceipt?.receiptHash; + const verificationSigValue = verificationSig ?? embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig; + const valuationValue = valuationSnapshot && valuationHash ? { ...valuationSnapshot, valuationHash } : undefined; + return { + capsuleHash, + pulse: proofCapsule.pulse, + verifiedAtPulse: stewardVerifiedPulse, + phikey: effectivePhiKey !== "—" ? effectivePhiKey : proofCapsule.phiKey, + kasOk: sealKAS === "valid", + g16Ok: sealZK === "valid", + verifierSlug: proofCapsule.verifierSlug, + verifier: verificationSource, + verificationVersion, + bundleHash: bundleHash || undefined, + zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, + receipt: receiptValue ?? undefined, + receiptHash: receiptHashValue || undefined, + verificationSig: verificationSigValue ?? undefined, + sigilSvg: svgText.trim() ? svgText : undefined, + valuation: valuationValue, + }; + }, [ + bundleHash, + capsuleHash, + embeddedProof?.receipt, + embeddedProof?.receiptHash, + embeddedProof?.verificationSig, + proofCapsule, + receiptHash, + result.status, + effectivePhiKey, + sealKAS, + sealZK, + sharedReceipt?.receipt, + sharedReceipt?.receiptHash, + sharedReceipt?.verificationSig, + stewardVerifiedPulse, + svgText, + valuationHash, + valuationSnapshot, + verificationReceipt, + verificationSig, + verificationSource, + verificationVersion, + zkMeta?.zkPoseidonHash, + ]); + + const onDownloadVerifiedCard = useCallback(async () => { + if (!verifiedCardData) return; + await downloadVerifiedCardPng(verifiedCardData); + }, [verifiedCardData]); + + const onSignVerification = useCallback(async () => { + if (!proofCapsule || !receiptHash) return; + if (verificationSigBusy) return; + if (!isWebAuthnAvailable()) { + setNotice("WebAuthn is not available in this browser. Please verify on a device with passkeys enabled."); + return; + } + setVerificationSigBusy(true); + try { + const kasSig = await signBundleHash(proofCapsule.phiKey, receiptHash); + const nextSig = verificationSigFromKas(kasSig); + const ok = await verifyVerificationSig(receiptHash, nextSig); + setVerificationSig(nextSig); + setVerificationSigVerified(ok); + setNotice(ok ? "Verification receipt signed." : "Verification receipt signature failed."); + } catch (err) { + const msg = err instanceof Error ? err.message : "Verification receipt signature failed."; + setNotice(msg); + } finally { + setVerificationSigBusy(false); + } + }, [proofCapsule, receiptHash, verificationSigBusy]); + + const onReceiveGlyph = useCallback(async () => { + if (!bundleHash || !proofCapsule || !capsuleHash || !svgHash) return; + if (receiveBusy) return; + if (!isWebAuthnAvailable()) { + setNotice("WebAuthn is not available in this browser. Please verify on a device with passkeys enabled."); + return; + } + + setReceiveBusy(true); + try { + const receivePulse = currentPulse ?? getKaiPulseEternalInt(new Date()); + const originSigCandidate = effectiveOriginAuthorSig ?? (embeddedProof?.authorSig ?? null); + const originAuthorSig = isKASAuthorSig(originSigCandidate) ? originSigCandidate : null; + const originBundleHash = effectiveOriginBundleHash ?? bundleHash; + const receiveBundleSeed: ProofBundleLike = { + hashAlg: embeddedProof?.hashAlg ?? PROOF_HASH_ALG, + canon: embeddedProof?.canon ?? PROOF_CANON, + bindings: embeddedProof?.bindings ?? PROOF_BINDINGS, + zkStatement: embeddedProof?.zkStatement, + bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot, + zkMeta: embeddedProof?.zkMeta, + proofCapsule, + capsuleHash, + svgHash, + zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, + zkProof: zkMeta?.zkProof ?? undefined, + zkPublicInputs: zkMeta?.zkPublicInputs ?? undefined, + }; + const receiveBundleRoot = buildReceiveBundleRoot({ + bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot ?? undefined, + bundle: receiveBundleSeed, + originBundleHash, + originAuthorSig, + receivePulse, + }); + const receiveBundleHash = await hashReceiveBundleRoot(receiveBundleRoot); + const passkey = await ensureReceiverPasskey(); + const { nonce, challengeBytes } = await buildKasChallenge("receive", receiveBundleHash); + const assertion = await getWebAuthnAssertionJson({ + challenge: challengeBytes, + allowCredIds: [passkey.credId], + preferInternal: true, + }); + const ok = await verifyWebAuthnAssertion({ + assertion, + expectedChallenge: challengeBytes, + pubKeyJwk: passkey.pubKeyJwk, + expectedCredId: passkey.credId, + }); + if (!ok) { + setNotice("Receive signature invalid."); + return; + } + + const nextSig: ReceiveSig = { + v: "KRS-1", + alg: "webauthn-es256", + nonce, + binds: { bundleHash: receiveBundleHash }, + createdAtPulse: receivePulse, + credId: passkey.credId, + pubKeyJwk: passkey.pubKeyJwk as ReceiveSig["pubKeyJwk"], + assertion, + }; + + const ownerPhiKey = await deriveOwnerPhiKeyFromReceive({ + receiverPubKeyJwk: nextSig.pubKeyJwk, + receivePulse, + receiveBundleHash, + }); + const ownerKeyDerivation = buildOwnerKeyDerivation({ + originPhiKey: proofCapsule?.phiKey, + receivePulse, + receiveBundleHash, + }); + + setReceiveSig(nextSig); + setReceiveSigVerified(true); + setLocalReceiveBundle({ + mode: "receive", + originBundleHash, + receiveBundleHash, + originAuthorSig, + receiveSig: nextSig, + receivePulse, + ownerPhiKey, + ownerKeyDerivation, + }); + setNotice("Receive signature recorded."); + } catch (err) { + const msg = err instanceof Error ? err.message : "Receive claim canceled."; + setNotice(msg); + } finally { + setReceiveBusy(false); + } + }, [ + bundleHash, + bundleRoot, + capsuleHash, + currentPulse, + effectiveOriginAuthorSig, + effectiveOriginBundleHash, + embeddedProof?.authorSig, + embeddedProof?.bindings, + embeddedProof?.bundleRoot, + embeddedProof?.canon, + embeddedProof?.hashAlg, + embeddedProof?.zkMeta, + embeddedProof?.zkStatement, + proofCapsule?.phiKey, + proofCapsule, + receiveBusy, + svgHash, + zkMeta?.zkPoseidonHash, + zkMeta?.zkProof, + zkMeta?.zkPublicInputs, + ]); + const sealStateLabel = useCallback((state: SealState): string => { switch (state) { case "valid": @@ -1365,7 +2306,7 @@ body: [ const hasSvgBytes = Boolean(svgText.trim()); const expectedSvgHash = sharedReceipt?.svgHash ?? embeddedProof?.svgHash ?? ""; - const hasKasIdentity = Boolean(embeddedProof?.authorSig && isKASAuthorSig(embeddedProof.authorSig)); + const hasKasIdentity = Boolean(provenanceSig && isKASAuthorSig(provenanceSig)); const identityStatusLabel = !hasSvgBytes || !hasKasIdentity ? "Not present" @@ -1387,33 +2328,6 @@ body: [ ? "Not present" : "No reference hash"; - const receiveCredId = useMemo(() => (receiveSig ? receiveSig.credId : ""), [receiveSig]); - const receiveNonce = useMemo(() => (receiveSig ? receiveSig.nonce : ""), [receiveSig]); - const receiveBundleHash = useMemo(() => (receiveSig?.binds.bundleHash ? receiveSig.binds.bundleHash : bundleHash || ""), [receiveSig, bundleHash]); - - const auditBundleText = useMemo(() => { - if (!proofCapsule) return ""; - return JSON.stringify( - { - hashAlg: PROOF_HASH_ALG, - canon: PROOF_CANON, - proofCapsule, - capsuleHash, - svgHash, - bundleHash, - shareUrl: embeddedProof?.shareUrl ?? null, - verifierUrl: proofVerifierUrl, - authorSig: embeddedProof?.authorSig ?? null, - zkPoseidonHash: zkMeta?.zkPoseidonHash ?? null, - zkProof: zkMeta?.zkProof ?? null, - proofHints: zkMeta?.proofHints ?? null, - zkPublicInputs: zkMeta?.zkPublicInputs ?? null, - }, - null, - 2, - ); - }, [proofCapsule, capsuleHash, svgHash, bundleHash, embeddedProof, proofVerifierUrl, zkMeta]); - const svgPreview = useMemo(() => { const raw = svgText.trim(); if (!raw) return ""; @@ -1423,13 +2337,189 @@ body: [ const verifierPulse = result.status === "ok" ? (result.embedded.pulse ?? (slug.pulse ?? 0)) : slug.pulse ?? 0; const verifierSig = result.status === "ok" ? (result.embedded.kaiSignature ?? (slug.shortSig ?? "unknown")) : slug.shortSig ?? "unknown"; - const verifierPhi = result.status === "ok" ? result.derivedPhiKey : "—"; + const verifierPhi = effectivePhiKey; const verifierChakra = result.status === "ok" ? result.embedded.chakraDay : undefined; const shareStatus = result.status === "ok" ? "VERIFIED" : result.status === "error" ? "FAILED" : "STANDBY"; const sharePhiShort = verifierPhi && verifierPhi !== "—" ? ellipsizeMiddle(verifierPhi, 12, 10) : "—"; const shareKas = sealKAS === "valid" ? "✅" : "❌"; const shareG16 = sealZK === "valid" ? "✅" : "❌"; + const verificationSigLabel = + verificationSigVerified === true ? "Verification signed" : verificationSigVerified === false ? "Verification signature invalid" : "Sign Verification"; + const canSignVerification = Boolean(receiptHash && proofCapsule); + const canReceiveGlyph = Boolean(bundleHash && result.status === "ok"); + const receiveActionLabel = effectiveReceiveSig ? "Ownership received" : "Receive / Accept Glyph"; + + const stewardPulseLabel = + stewardVerifiedPulse == null ? "Verified pulse unavailable (legacy bundle)" : `Steward Verified @ Pulse ${stewardVerifiedPulse}`; + + React.useEffect(() => { + let active = true; + if (!effectiveReceiveSig) { + setReceiveSigVerified(null); + return; + } + + (async () => { + const receiveBundleHashValue = effectiveReceiveBundleHash || effectiveReceiveSig.binds.bundleHash; + if (!receiveBundleHashValue) { + if (active) setReceiveSigVerified(false); + return; + } + const nonce = effectiveReceiveSig.nonce ?? ""; + const { challengeBytes } = await buildKasChallenge("receive", receiveBundleHashValue, nonce); + const ok = await verifyWebAuthnAssertion({ + assertion: effectiveReceiveSig.assertion, + expectedChallenge: challengeBytes, + pubKeyJwk: effectiveReceiveSig.pubKeyJwk, + expectedCredId: effectiveReceiveSig.credId, + }); + if (active) setReceiveSigVerified(ok); + })(); + + return () => { + active = false; + }; + }, [effectiveReceiveBundleHash, effectiveReceiveSig]); + + + React.useEffect(() => { + let active = true; + if (!verificationReceipt) { + setReceiptHash(""); + return; + } + (async () => { + const hash = await hashVerificationReceipt(verificationReceipt); + if (active) setReceiptHash(hash); + })(); + return () => { + active = false; + }; + }, [verificationReceipt]); + +React.useEffect(() => { + let active = true; + + const poseidon = + typeof zkMeta?.zkPoseidonHash === "string" && zkMeta.zkPoseidonHash.trim().length > 0 + ? zkMeta.zkPoseidonHash + : undefined; + + if (!bundleHash || !poseidon) { + setCacheKey(""); + return; + } + + (async () => { + const key = await buildVerificationCacheKey({ + bundleHash, + zkPoseidonHash: poseidon, + verificationVersion: cacheVerificationVersion, + }); + if (active) setCacheKey(key); + })(); + + return () => { + active = false; + }; +}, [bundleHash, cacheVerificationVersion, zkMeta?.zkPoseidonHash]); + + const auditBundleText = useMemo(() => { + if (!proofCapsule) return ""; + const transport = { + shareUrl: embeddedProof?.transport?.shareUrl ?? embeddedProof?.shareUrl, + verifierUrl: embeddedProof?.transport?.verifierUrl ?? proofVerifierUrl, + verifier: embeddedProof?.transport?.verifier ?? embeddedProof?.verifier ?? verificationSource, + verifiedAtPulse: embeddedProof?.transport?.verifiedAtPulse ?? stewardVerifiedPulse ?? undefined, + proofHints: embeddedProof?.transport?.proofHints ?? embeddedProof?.proofHints ?? zkMeta?.proofHints ?? undefined, + }; + const zkVerified = zkMeta?.zkPoseidonHash && typeof zkVerify === "boolean" ? zkVerify : undefined; + const receiptValue = verificationReceipt ?? embeddedProof?.receipt ?? sharedReceipt?.receipt; + const receiptHashValue = receiptHash || embeddedProof?.receiptHash || sharedReceipt?.receiptHash; + const verificationSigValue = verificationSig ?? embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig; + const cacheKeyValue = + verificationCacheEntry?.cacheKey ?? (cacheKey || embeddedProof?.cacheKey || sharedReceipt?.cacheKey); + const verificationCacheValue = verificationCacheEntry + ? { + ...verificationCacheEntry, + ...(zkVerifiedCached ? { zkVerifiedCached: true } : {}), + } + : embeddedProof?.verificationCache ?? sharedReceipt?.verificationCache; + const authorSigForExport = effectiveReceiveMode === "receive" || effectiveOriginAuthorSig ? null : embeddedProof?.authorSig ?? null; + const normalized = normalizeBundle({ + hashAlg: PROOF_HASH_ALG, + canon: PROOF_CANON, + bindings: embeddedProof?.bindings ?? PROOF_BINDINGS, + zkStatement: + embeddedProof?.zkStatement ?? + (zkMeta?.zkPoseidonHash + ? { + publicInputOf: ZK_STATEMENT_BINDING, + domainTag: ZK_STATEMENT_DOMAIN, + publicInputsContract: ZK_PUBLIC_INPUTS_CONTRACT, + encoding: ZK_STATEMENT_ENCODING, + } + : undefined), + bundleRoot: bundleRoot ?? embeddedProof?.bundleRoot, + proofCapsule, + capsuleHash, + svgHash, + bundleHash, + mode: effectiveReceiveMode, + originBundleHash: effectiveOriginBundleHash, + receiveBundleHash: effectiveReceiveBundleHash, + originAuthorSig: effectiveOriginAuthorSig ?? null, + receiveSig: effectiveReceiveSig ?? null, + receivePulse: effectiveReceivePulse ?? undefined, + ownerPhiKey: effectiveOwnerPhiKey ?? undefined, + ownerKeyDerivation: effectiveOwnerKeyDerivation, + authorSig: authorSigForExport, + zkPoseidonHash: zkMeta?.zkPoseidonHash ?? undefined, + zkProof: zkMeta?.zkProof ?? undefined, + zkPublicInputs: zkMeta?.zkPublicInputs ?? undefined, + zkMeta: embeddedProof?.zkMeta, + verificationCache: verificationCacheValue, + cacheKey: cacheKeyValue, + receipt: receiptValue ?? undefined, + receiptHash: receiptHashValue || undefined, + verificationSig: verificationSigValue ?? undefined, + transport, + }); + + const withZkVerified = typeof zkVerified === "boolean" ? { ...normalized, zkVerified } : normalized; + return JSON.stringify(withZkVerified, null, 2); + }, [ + proofCapsule, + capsuleHash, + svgHash, + bundleHash, + embeddedProof, + effectiveOriginAuthorSig, + effectiveOriginBundleHash, + effectiveOwnerKeyDerivation, + effectiveOwnerPhiKey, + effectiveReceiveBundleHash, + effectiveReceiveMode, + effectiveReceivePulse, + effectiveReceiveSig, + proofVerifierUrl, + stewardVerifiedPulse, + verificationSource, + zkMeta, + zkVerify, + bundleRoot, + cacheKey, + receiptHash, + sharedReceipt?.cacheKey, + sharedReceipt?.receipt, + sharedReceipt?.receiptHash, + sharedReceipt?.verificationSig, + verificationCacheEntry, + verificationReceipt, + verificationSig, + zkVerifiedCached, + ]); const receiptJson = useMemo(() => { if (!proofCapsule) return ""; @@ -1442,21 +2532,95 @@ body: [ } as const; const extended: Record = { ...receipt }; + const receiptValue = verificationReceipt ?? embeddedProof?.receipt ?? sharedReceipt?.receipt; + const receiptHashValue = receiptHash || embeddedProof?.receiptHash || sharedReceipt?.receiptHash; + const verificationSigValue = verificationSig ?? embeddedProof?.verificationSig ?? sharedReceipt?.verificationSig; + const cacheKeyValue = + verificationCacheEntry?.cacheKey ?? (cacheKey || embeddedProof?.cacheKey || sharedReceipt?.cacheKey); + const verificationCacheValue = verificationCacheEntry + ? { + ...verificationCacheEntry, + ...(zkVerifiedCached ? { zkVerifiedCached: true } : {}), + } + : embeddedProof?.verificationCache ?? sharedReceipt?.verificationCache; + extended.verifier = verificationSource; + extended.verificationVersion = verificationVersion; + if (typeof stewardVerifiedPulse === "number" && Number.isFinite(stewardVerifiedPulse)) { + extended.verifiedAtPulse = stewardVerifiedPulse; + } if (svgHash) extended.svgHash = svgHash; if (bundleHash) extended.bundleHash = bundleHash; - if (embeddedProof?.shareUrl) extended.shareUrl = embeddedProof.shareUrl; - if (embeddedProof?.authorSig) extended.authorSig = embeddedProof.authorSig; + if (effectiveReceiveMode) extended.mode = effectiveReceiveMode; + if (effectiveOriginBundleHash) extended.originBundleHash = effectiveOriginBundleHash; + if (effectiveReceiveBundleHash) extended.receiveBundleHash = effectiveReceiveBundleHash; + const shareUrlValue = embeddedProof?.transport?.shareUrl ?? embeddedProof?.shareUrl; + if (shareUrlValue) extended.shareUrl = shareUrlValue; + if (effectiveOriginAuthorSig) extended.originAuthorSig = effectiveOriginAuthorSig; + if (effectiveReceiveSig) extended.receiveSig = effectiveReceiveSig; + if (effectiveReceivePulse != null) extended.receivePulse = effectiveReceivePulse; + if (effectiveOwnerPhiKey) extended.ownerPhiKey = effectiveOwnerPhiKey; + if (effectiveOwnerKeyDerivation) extended.ownerKeyDerivation = effectiveOwnerKeyDerivation; + if (embeddedProof?.authorSig && !effectiveOriginAuthorSig && effectiveReceiveMode !== "receive") { + extended.authorSig = embeddedProof.authorSig; + } if (embeddedProof?.zkProof) extended.zkProof = embeddedProof.zkProof; - if (embeddedProof?.proofHints) extended.proofHints = embeddedProof.proofHints; + const proofHintsValue = embeddedProof?.transport?.proofHints ?? embeddedProof?.proofHints; + if (proofHintsValue) extended.proofHints = proofHintsValue; if (embeddedProof?.zkPublicInputs) extended.zkPublicInputs = embeddedProof.zkPublicInputs; if (zkMeta?.zkPoseidonHash) { extended.zkPoseidonHash = zkMeta.zkPoseidonHash; - extended.zkVerified = Boolean(zkVerify); extended.zkScheme = "groth16-poseidon"; + if (typeof zkVerify === "boolean") { + extended.zkVerified = zkVerify; + } + } + if (verificationCacheValue) { + extended.verificationCache = verificationCacheValue; } + if (cacheKeyValue) extended.cacheKey = cacheKeyValue; + if (receiptValue) extended.receipt = receiptValue; + if (receiptHashValue) extended.receiptHash = receiptHashValue; + if (verificationSigValue) extended.verificationSig = verificationSigValue; return jcsCanonicalize(extended as Parameters[0]); - }, [bundleHash, capsuleHash, currentVerifyUrl, embeddedProof?.shareUrl, proofCapsule, proofVerifierUrl, svgHash, zkMeta?.zkPoseidonHash, zkVerify]); + }, [ + bundleHash, + capsuleHash, + currentVerifyUrl, + embeddedProof?.shareUrl, + embeddedProof?.transport?.shareUrl, + embeddedProof?.proofHints, + embeddedProof?.transport?.proofHints, + embeddedProof?.authorSig, + embeddedProof?.zkProof, + embeddedProof?.zkPublicInputs, + effectiveOriginAuthorSig, + effectiveOriginBundleHash, + effectiveOwnerKeyDerivation, + effectiveOwnerPhiKey, + effectiveReceiveBundleHash, + effectiveReceiveMode, + effectiveReceivePulse, + effectiveReceiveSig, + proofCapsule, + proofVerifierUrl, + stewardVerifiedPulse, + verificationSource, + verificationVersion, + svgHash, + zkMeta?.zkPoseidonHash, + zkVerify, + cacheKey, + receiptHash, + sharedReceipt?.cacheKey, + sharedReceipt?.receipt, + sharedReceipt?.receiptHash, + sharedReceipt?.verificationSig, + verificationCacheEntry, + verificationReceipt, + verificationSig, + zkVerifiedCached, + ]); const shareReceiptUrl = useMemo(() => { if (!receiptJson) return ""; @@ -1558,7 +2722,7 @@ body: [ @@ -1596,8 +2760,32 @@ body: [ + + +
+ ) : null}
@@ -1614,7 +2802,7 @@ body: [ >
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
-
{chartFocus === "phi" ? "Φ Resonance · Live" : "$ Price · Live"}
+
{chartMode === "phi" ? "Φ Resonance · Live" : "$ Price · Live"}
@@ -1629,7 +2817,8 @@ body: [ momentX={1} colors={["rgba(167,255,244,1)"]} usdPerPhi={usdPerPhi} - mode={chartFocus === "usd" ? "usd" : "phi"} + mode={chartMode === "usd" ? "usd" : "phi"} + dataUnit={isReceiveGlyph ? "usd" : "phi"} reflowKey={chartReflowKey} /> @@ -1827,7 +3016,7 @@ body: [
Attestation Spine
-
vesselHash + sigilHash → bundleHash (offline integrity rail).
+
bundleRoot → bundleHash (capsuleHash + sigilHash + ZK → integrity rail).
@@ -1852,6 +3041,18 @@ body: [ void remember(proofVerifierUrl, "Verifier URL")} disabled={!proofVerifierUrl} />
+
+ steward pulse + {stewardPulseLabel} + void remember(String(stewardVerifiedPulse ?? ""), "Steward verification pulse")} + disabled={stewardVerifiedPulse == null} + /> +
+
sigilHash @@ -1868,14 +3069,55 @@ body: [ void remember(capsuleHash, "Vessel hash")} disabled={!capsuleHash} />
-
- bundleHash - - {bundleHash ? ellipsizeMiddle(bundleHash, 22, 16) : "—"} - - void remember(bundleHash, "Bundle hash")} disabled={!bundleHash} /> +
+ bundleHash + + {bundleHash ? ellipsizeMiddle(bundleHash, 22, 16) : "—"} + + void remember(bundleHash, "Bundle hash")} disabled={!bundleHash} /> +
+
+ +
Advanced · Self-Describing Proof
+
+
+ capsuleHashOf + {proofBindings.capsuleHashOf} + void remember(proofBindings.capsuleHashOf, "capsuleHashOf")} /> +
+ +
+ bundleHashOf + {proofBindings.bundleHashOf} + void remember(proofBindings.bundleHashOf, "bundleHashOf")} /> +
+ +
+ authorChallengeOf + {proofBindings.authorChallengeOf} + void remember(proofBindings.authorChallengeOf, "authorChallengeOf")} /> +
+ +
+ publicInputOf + {zkStatementValue?.publicInputOf ?? "—"} + void remember(String(zkStatementValue?.publicInputOf ?? ""), "publicInputOf")} disabled={!zkStatementValue?.publicInputOf} /> +
+ +
+ domainTag + {zkStatementValue?.domainTag ?? "—"} + void remember(String(zkStatementValue?.domainTag ?? ""), "domainTag")} disabled={!zkStatementValue?.domainTag} /> +
+ +
+ publicInputsContract + + {publicInputsContractLabel} + + void remember(publicInputsContractLabel, "publicInputsContract")} disabled={publicInputsContractLabel === "—"} /> +
-
@@ -1994,10 +3236,13 @@ body: [
- - - - + + + + + + + @@ -2011,7 +3256,7 @@ body: [ />
- {receiveSig ? ( + {effectiveReceiveSig ? (
- setOpenAuditJson(false)}> + setOpenAuditJson(false)}>