");
+
+ 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 `
+
+ `.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 `
+
+ `.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 = /