diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts
index 4a0879d..6657cce 100644
--- a/apps/web/e2e/marketplace-checkout.spec.ts
+++ b/apps/web/e2e/marketplace-checkout.spec.ts
@@ -105,6 +105,30 @@ test('a fresh connected browser recovers active access from chain and relay stat
).toBeVisible();
});
+test('proof explorer resolves an order ID and recomputes every cross-chain invariant', async ({
+ page,
+}) => {
+ await mockMarketplaceRpc(page);
+ await page.route('https://relay.invalid/proofs/**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(publicProofEvidence()),
+ });
+ });
+ await page.goto(`/proofs/${orderId}`);
+
+ await expect(
+ page.getByRole('heading', { name: 'Access verified on Creditcoin' }),
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('.invariant-row.pass')).toHaveCount(11);
+ await expect(page.getByText('Native query proof')).toBeVisible();
+ await expect(page.getByText('Continuity path')).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Download JSON' }),
+ ).toBeVisible();
+});
+
async function mockMarketplaceRpc(page: Page, includeUsage = false) {
await page.route('https://**.rpc.proofkey.invalid/**', async (route) => {
const request = route.request();
@@ -140,6 +164,10 @@ function rpcResult(
result = isCreditcoin ? '0x18e8f' : '0xaa36a7';
else if (rpc.method === 'eth_blockNumber')
result = isCreditcoin ? '0x539000' : '0xb26c00';
+ else if (rpc.method === 'eth_getTransactionByHash')
+ result = sourceTransaction(rpc.params?.[0] as string);
+ else if (rpc.method === 'eth_getTransactionReceipt')
+ result = isCreditcoin ? creditcoinReceipt() : sourceReceipt();
else if (rpc.method === 'eth_getLogs') {
const topics = (rpc.params?.[0] as { topics?: unknown[] })?.topics ?? [];
const isUsageQuery = topics.length > 1 && topics[1] === null;
@@ -208,6 +236,111 @@ function rpcResult(
return result;
}
+function publicProofEvidence() {
+ return {
+ schema: 'proofkey.public-proof.v1',
+ source: {
+ transactionHash: `0x${'fa'.repeat(32)}`,
+ blockNumber: 11_693_054,
+ payment: {
+ orderId,
+ machineId,
+ payer: owner,
+ beneficiary: owner,
+ startTime: '1500',
+ duration: '1000',
+ amount: '2500000',
+ },
+ },
+ relay: {
+ phase: 'completed',
+ createdAt: '2026-09-13T10:00:00.000Z',
+ updatedAt: '2026-09-13T10:05:00.000Z',
+ attempts: { proof_generation: 1, creditcoin_execution: 1 },
+ },
+ attestcoin: {
+ chainKey: 1,
+ blockHeight: 11_693_054,
+ encodedTransaction: '0x01',
+ merkleRoot: `0x${'31'.repeat(32)}`,
+ siblings: [{ hash: `0x${'32'.repeat(32)}`, isLeft: true }],
+ lowerEndpointDigest: `0x${'33'.repeat(32)}`,
+ continuityRoots: [`0x${'34'.repeat(32)}`],
+ },
+ creditcoin: {
+ transactionHash: `0x${'ab'.repeat(32)}`,
+ queryId: `0x${'12'.repeat(32)}`,
+ accessExpiresAt: '2500',
+ },
+ };
+}
+
+function sourceTransaction(hash: string) {
+ return {
+ hash,
+ blockHash: `0x${'a1'.repeat(32)}`,
+ blockNumber: '0xb26bfe',
+ transactionIndex: '0x0',
+ from: owner,
+ to: '0x0000000000000000000000000000000000000001',
+ nonce: '0x1',
+ gas: '0x5208',
+ gasPrice: '0x1',
+ input: '0x',
+ value: '0x0',
+ type: '0x0',
+ chainId: '0xaa36a7',
+ v: '0x1b',
+ r: `0x${'01'.repeat(32)}`,
+ s: `0x${'02'.repeat(32)}`,
+ };
+}
+
+function sourceReceipt() {
+ return transactionReceipt(
+ `0x${'fa'.repeat(32)}`,
+ `0x${'a1'.repeat(32)}`,
+ '0xb26bfe',
+ '0x0000000000000000000000000000000000000001',
+ [usagePaid()],
+ );
+}
+
+function creditcoinReceipt() {
+ return transactionReceipt(
+ `0x${'ab'.repeat(32)}`,
+ `0x${'a2'.repeat(32)}`,
+ '0x539010',
+ '0x0000000000000000000000000000000000000005',
+ [activation()],
+ );
+}
+
+function transactionReceipt(
+ transactionHash: string,
+ blockHash: string,
+ blockNumber: string,
+ to: string,
+ logs: unknown[],
+) {
+ return {
+ transactionHash,
+ transactionIndex: '0x0',
+ blockHash,
+ blockNumber,
+ from: owner,
+ to,
+ cumulativeGasUsed: '0x5208',
+ gasUsed: '0x5208',
+ contractAddress: null,
+ logs,
+ logsBloom: `0x${'00'.repeat(256)}`,
+ status: '0x1',
+ effectiveGasPrice: '0x1',
+ type: '0x0',
+ };
+}
+
function usagePaid() {
const encoded = usageInterface.encodeEventLog(
usageInterface.getEvent('UsagePaid')!,
diff --git a/apps/web/package.json b/apps/web/package.json
index 2cf1f6d..33df8ec 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.json --noEmit && vite build",
"dev": "vite --host 0.0.0.0",
- "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js",
+ "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js dist-test/proof.test.js",
"test:e2e": "playwright test",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
diff --git a/apps/web/src/pages/ProofPage.tsx b/apps/web/src/pages/ProofPage.tsx
index 22cab4c..5d081a3 100644
--- a/apps/web/src/pages/ProofPage.tsx
+++ b/apps/web/src/pages/ProofPage.tsx
@@ -1,188 +1,603 @@
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
+ Blocks,
Check,
+ CircleAlert,
+ Clock3,
+ Download,
ExternalLink,
+ FileJson,
+ Fingerprint,
+ Link2,
+ Network,
Search,
ShieldCheck,
+ X,
} from 'lucide-react';
import { useState } from 'react';
-import { useNavigate, useParams } from 'react-router';
+import { Link, useNavigate, useParams } from 'react-router';
import { useRuntime } from '../app/AppProviders.js';
import { DataState, PageHeading } from '../components/ProductUI.js';
-import { progressFromJob } from '../flow.js';
-import { compactHash, isTransactionHash, proofPath } from '../product.js';
-import { ProofWorkerClient } from '../worker.js';
+import { progressFromJob, type RelayPhase } from '../flow.js';
+import {
+ ProofExplorerClient,
+ downloadPublicEvidence,
+ type ProofInvariant,
+ type ProofRecord,
+} from '../proof.js';
+import { compactHash, isTransactionHash, machinePath } from '../product.js';
+import { ProofLookupError } from '../worker.js';
+
+const phases: Array<{ key: RelayPhase; label: string }> = [
+ { key: 'source_confirmation', label: 'Source receipt' },
+ { key: 'attestation_wait', label: 'Block coverage' },
+ { key: 'proof_generation', label: 'Proof built' },
+ { key: 'creditcoin_execution', label: 'CC3 execution' },
+ { key: 'completed', label: 'Access result' },
+];
export function Component() {
- const { sourceTxHash = 'search' } = useParams();
+ const { sourceTxHash: identifier = 'search' } = useParams();
const navigate = useNavigate();
const { config } = useRuntime();
const [search, setSearch] = useState(
- sourceTxHash === 'search' ? '' : sourceTxHash,
+ identifier === 'search' ? '' : identifier,
);
- const validHash = isTransactionHash(sourceTxHash);
- const job = useQuery({
- queryKey: ['proof', sourceTxHash],
- queryFn: () => new ProofWorkerClient(config!.workerUrl).get(sourceTxHash),
- enabled: Boolean(config && validHash),
- retry: false,
+ const validIdentifier = isTransactionHash(identifier);
+ const record = useQuery({
+ queryKey: ['public-proof', identifier],
+ queryFn: () => new ProofExplorerClient(config!).load(identifier),
+ enabled: Boolean(config && validIdentifier),
+ retry: (count, error) =>
+ !(error instanceof ProofLookupError && error.status === 404) && count < 2,
refetchInterval: (query) => {
- const phase = query.state.data?.phase;
- return phase === 'completed' ||
- phase === 'duplicate' ||
- phase === 'failed'
- ? false
- : 5_000;
+ const phase = query.state.data?.evidence.relay.phase;
+ return phase && !['completed', 'duplicate', 'failed'].includes(phase)
+ ? 5_000
+ : false;
},
});
- const progress = job.data ? progressFromJob(job.data) : undefined;
function submit(event: React.FormEvent) {
event.preventDefault();
- if (isTransactionHash(search.trim())) navigate(proofPath(search.trim()));
+ const value = search.trim();
+ if (isTransactionHash(value)) navigate(`/proofs/${value}`);
}
return (
+
+ Source transaction
+ Order ID
+ Query ID
+ CC3 transaction
+
+
+ {identifier === 'search' ? (
+
+ ) : !validIdentifier ? (
+
+ ) : record.isLoading ? (
+
+ ) : record.isError ? (
+
+ ) : record.data ? (
+
+ ) : null}
+
+ );
+}
+
+function ProofIntroduction() {
+ return (
+ <>
+
+
+ 01
+
+ Read the source
+
+ Decode the confirmed UsagePaid receipt and identify the payer,
+ machine, duration, beneficiary, and amount.
+
+
+
+ 02
+
+ Inspect Attestcoin
+
+ Examine covered height, Merkle path, continuity endpoint, and the
+ native query carried into Creditcoin.
+
+
+
+ 03
+
+ Recompute policy
+
+ Compare proven values with the live Creditcoin owner, tariff, replay
+ guard, and AccessPass expiry.
+
+
+
+
+
+
+
No wallet connection required
+
+ Verification is public and read-only. Share this URL with an
+ auditor, machine operator, or renter.
+
+
+
+ >
+ );
+}
+
+function ProofError({
+ error,
+ identifier,
+}: {
+ error: Error;
+ identifier: string;
+}) {
+ const unknown = error instanceof ProofLookupError && error.status === 404;
+ return (
+
+ Check Sepolia directly
+
+ }
+ />
+ );
+}
- {sourceTxHash === 'search' ? (
-
+function ProofDetails({ record }: { record: ProofRecord }) {
+ const { evidence, chain, invariants } = record;
+ const progress = progressFromJob({
+ sourceTransactionHash: evidence.source.transactionHash,
+ phase: evidence.relay.phase,
+ failedAtPhase: evidence.relay.failedAtPhase,
+ error: evidence.relay.failure?.message,
+ });
+ const passed = invariants.filter((item) => item.status === 'pass').length;
+ const failed = invariants.filter((item) => item.status === 'fail').length;
+ const phaseClass = stateClass(evidence.relay.phase, failed);
+
+ return (
+
+
+
+
+ {phaseIcon(evidence.relay.phase, failed)}
+ {stateLabel(evidence.relay.phase, failed)}
+
+
{progress.label}
+ {evidence.source.transactionHash}
+
+
+
+ {passed}/{invariants.length}
+
+
+ {failed ? `${failed} failed checks` : 'computed checks pass'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ POLICY VERDICT
+
Expected vs. proven
+
+
+ These rows are computed from the Sepolia receipt, Attestcoin proof,
+ and current Creditcoin contract state.
+
+
+
+
+ Invariant
+ Expected
+ Actual
+ Result
+
+ {invariants.map((item) => (
+
+ ))}
+
+
+
+
+
+
+ DECODED USAGEPAID
+
Source values
+
+
+
+
+
+
+
+
+
+
+ {evidence.source.payment?.machineId && (
+
+ Open machine profile
+
+ )}
+
+
+
+
+ ATTESTCOIN MATERIAL
+
Continuity path
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {evidence.relay.failure && (
+
+
+
+
{evidence.relay.failure.code}
+
{evidence.relay.failure.message}
+
+ Stopped at {evidence.relay.failure.phase.replaceAll('_', ' ')}
+
+
+
+ )}
+
+
+
+
+ HOW TO READ THIS
+
Finality and trust boundary
+
+
+
- 01
-
- Transaction inclusion
+
+ Finality
- The successful receipt and transaction are proven inside a covered
- canonical Sepolia block.
+ Proof building begins only after the configured Sepolia
+ confirmations and Attestcoin block coverage.
- 02
-
- Continuity evidence
+
+ Replay protection
- Attestcoin continuity roots bind the covered height to the
- verifier’s trusted state.
+ ProofKeyASC consumes both query ID and order ID once. A second
+ execution reverts without granting access.
- 03
-
- Policy execution
+
+ Trust boundary
- ProofKeyASC validates payer, owner, tariff, duration, target,
- expiry, and replay state.
+ The relay transports proof bytes; it cannot grant access.
+ Creditcoin’s verifier and ProofKeyASC enforce the result.
-
- ) : !validHash ? (
-
- ) : job.isError ? (
-
- View source transaction
-
- )
+
+
+
+
+
+
+
+ Public evidence bundle
+
+ Proof bytes and public chain facts only—no RPC URLs, keys, or
+ internal paths.
+
+
+
+
downloadPublicEvidence(evidence)}>
+ Download JSON
+
+
+
+ );
+}
+
+function ProofTimeline({
+ phase,
+ failedAtPhase,
+}: {
+ phase: RelayPhase;
+ failedAtPhase?: RelayPhase;
+}) {
+ const current = phaseIndex(phase === 'failed' ? failedAtPhase : phase);
+ return (
+
+ {phases.map((item, index) => (
+
- ) : job.isLoading ? (
-
- ) : job.data && progress ? (
-
-
-
-
- {job.data.phase.replaceAll('_', ' ')}
-
-
{progress.label}
- {sourceTxHash}
-
-
- {progress.completed.length}/4
- stages accepted
-
-
-
- {[
- 'Successful source receipt',
- 'Canonical block coverage',
- 'Merkle + continuity proof',
- 'Creditcoin policy execution',
- ].map((label, index) => (
-
-
- {index < progress.completed.length ? (
-
- ) : (
- index + 1
- )}
-
- {label}
-
- {index < progress.completed.length ? 'Accepted' : 'Pending'}
-
-
- ))}
-
-
-
- ) : null}
+
+
{item.label}
+
+ ))}
);
}
+
+function ChainCard({
+ eyebrow,
+ title,
+ value,
+ meta,
+ externalHref,
+}: {
+ eyebrow: string;
+ title: string;
+ value: string;
+ meta: string;
+ externalHref?: string;
+}) {
+ return (
+
+ {eyebrow}
+ {title}
+ {value}
+ {meta}
+ {externalHref && (
+
+ Explorer
+
+ )}
+
+ );
+}
+
+function InvariantRow({ invariant }: { invariant: ProofInvariant }) {
+ return (
+
+
+
+ {invariant.status === 'pass' ? (
+
+ ) : invariant.status === 'fail' ? (
+
+ ) : (
+
+ )}
+
+
+ {invariant.label}
+ {invariant.explanation}
+
+
+
+ {compactHash(invariant.expected, 18, 12)}
+
+
+ {compactHash(invariant.actual, 18, 12)}
+
+ {invariant.status}
+
+ );
+}
+
+function Detail({ label, value }: { label: string; value?: string }) {
+ return (
+
+
{label}
+ {value ? compactHash(value, 18, 12) : 'Pending'}
+
+ );
+}
+
+function phaseIndex(phase?: RelayPhase) {
+ if (phase === 'queued') return 0;
+ if (phase === 'duplicate') return phases.length;
+ if (!phase || phase === 'failed') return 0;
+ return Math.max(
+ 0,
+ phases.findIndex((item) => item.key === phase),
+ );
+}
+
+function stateClass(phase: RelayPhase, failedInvariants: number) {
+ if (phase === 'failed' || failedInvariants) return 'failed';
+ if (phase === 'completed') return 'verified';
+ if (phase === 'duplicate') return 'duplicate';
+ return 'pending';
+}
+
+function stateLabel(phase: RelayPhase, failedInvariants: number) {
+ if (failedInvariants) return 'Invariant mismatch';
+ return {
+ queued: 'Queued',
+ source_confirmation: 'Confirming source',
+ attestation_wait: 'Awaiting block coverage',
+ proof_generation: 'Building proof',
+ creditcoin_execution: 'Executing on CC3',
+ completed: 'Verified access proof',
+ duplicate: 'Verified duplicate',
+ failed: 'Proof failed',
+ }[phase];
+}
+
+function phaseIcon(phase: RelayPhase, failedInvariants: number) {
+ if (phase === 'failed' || failedInvariants) return ;
+ if (phase === 'completed' || phase === 'duplicate')
+ return ;
+ return ;
+}
+
+function formatTimestamp(value?: string) {
+ if (!value) return undefined;
+ const milliseconds = Number(value) * 1_000;
+ return Number.isFinite(milliseconds)
+ ? new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(milliseconds)
+ : value;
+}
diff --git a/apps/web/src/proof.test.ts b/apps/web/src/proof.test.ts
new file mode 100644
index 0000000..cae7882
--- /dev/null
+++ b/apps/web/src/proof.test.ts
@@ -0,0 +1,159 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import {
+ assertSafePublicEvidence,
+ computeProofInvariants,
+ type AttestcoinEvidence,
+ type PublicProofEvidence,
+} from './proof.js';
+
+interface RecordedProof {
+ source: { transactionHash: string; blockNumber: number };
+ proof: AttestcoinEvidence;
+}
+
+interface RecordedRun {
+ demo: { tariff: string; machineOwner: string };
+ deployments: {
+ sepolia: { usagePaymentRegistry: { address: string } };
+ };
+ payment: {
+ orderId: string;
+ machineId: string;
+ payer: string;
+ beneficiary: string;
+ startTime: string;
+ duration: string;
+ amount: string;
+ transactionHash: string;
+ blockNumber: number;
+ };
+ authorization: { expiresAt: string };
+}
+
+async function recordedFixture() {
+ const [proof, run] = await Promise.all([
+ readFile(
+ new URL(
+ '../../../packages/contracts/fixtures/recorded-live-proof.json',
+ import.meta.url,
+ ),
+ 'utf8',
+ ).then((value) => JSON.parse(value) as RecordedProof),
+ readFile(
+ new URL(
+ '../../../packages/contracts/deployments/live-mvp.json',
+ import.meta.url,
+ ),
+ 'utf8',
+ ).then((value) => JSON.parse(value) as RecordedRun),
+ ]);
+ return { proof, run };
+}
+
+test('decodes the recorded live Attestcoin proof into passing invariants', async () => {
+ const { proof, run } = await recordedFixture();
+ const evidence: PublicProofEvidence = {
+ schema: 'proofkey.public-proof.v1',
+ source: {
+ transactionHash: proof.source.transactionHash,
+ blockNumber: proof.source.blockNumber,
+ payment: run.payment,
+ },
+ relay: {
+ phase: 'completed',
+ createdAt: '2026-09-12T20:50:00.000Z',
+ updatedAt: '2026-09-12T20:57:50.109Z',
+ attempts: {},
+ },
+ attestcoin: proof.proof,
+ creditcoin: { accessExpiresAt: run.authorization.expiresAt },
+ };
+ const invariants = computeProofInvariants(
+ evidence,
+ {
+ receiptStatus: 1,
+ sourceTo: run.deployments.sepolia.usagePaymentRegistry.address,
+ sourceFrom: run.payment.payer,
+ payment: run.payment,
+ machine: {
+ owner: run.demo.machineOwner,
+ tariff: run.demo.tariff,
+ active: true,
+ },
+ orderProcessed: true,
+ authorizationId: run.payment.orderId,
+ accessExpiresAt: run.authorization.expiresAt,
+ },
+ { registryAddress: run.deployments.sepolia.usagePaymentRegistry.address },
+ );
+
+ assert.equal(invariants.length, 11);
+ assert.deepEqual(
+ invariants.filter((item) => item.status !== 'pass'),
+ [],
+ );
+ assert.equal(
+ invariants.find((item) => item.key === 'tariff')?.expected,
+ run.payment.amount,
+ );
+});
+
+test('marks mismatched proof values as failures with expected and actual values', async () => {
+ const { proof, run } = await recordedFixture();
+ const evidence: PublicProofEvidence = {
+ schema: 'proofkey.public-proof.v1',
+ source: {
+ transactionHash: run.payment.transactionHash,
+ blockNumber: run.payment.blockNumber,
+ payment: { ...run.payment, amount: '1' },
+ },
+ relay: {
+ phase: 'failed',
+ createdAt: '2026-09-12T20:50:00.000Z',
+ updatedAt: '2026-09-12T20:57:50.109Z',
+ attempts: {},
+ },
+ attestcoin: { ...proof.proof, chainKey: 2 },
+ creditcoin: {},
+ };
+ const invariants = computeProofInvariants(
+ evidence,
+ {
+ receiptStatus: 0,
+ sourceTo: run.deployments.sepolia.usagePaymentRegistry.address,
+ sourceFrom: run.payment.payer,
+ payment: evidence.source.payment,
+ machine: {
+ owner: run.demo.machineOwner,
+ tariff: run.demo.tariff,
+ active: true,
+ },
+ orderProcessed: false,
+ },
+ { registryAddress: run.deployments.sepolia.usagePaymentRegistry.address },
+ );
+ assert.equal(
+ invariants.find((item) => item.key === 'receipt')?.status,
+ 'fail',
+ );
+ assert.equal(
+ invariants.find((item) => item.key === 'chain-key')?.actual,
+ 'chain key 2',
+ );
+ assert.equal(
+ invariants.find((item) => item.key === 'tariff')?.status,
+ 'fail',
+ );
+});
+
+test('raw evidence export rejects secret-bearing fields recursively', () => {
+ assert.throws(
+ () => assertSafePublicEvidence({ proof: { workerPrivateKey: '0xdead' } }),
+ /Unsafe public evidence field/,
+ );
+ assert.doesNotThrow(() =>
+ assertSafePublicEvidence({ proof: { merkleRoot: `0x${'12'.repeat(32)}` } }),
+ );
+});
diff --git a/apps/web/src/proof.ts b/apps/web/src/proof.ts
new file mode 100644
index 0000000..6a1c119
--- /dev/null
+++ b/apps/web/src/proof.ts
@@ -0,0 +1,416 @@
+import {
+ Contract,
+ Interface,
+ JsonRpcProvider,
+ getAddress,
+ type TransactionReceipt,
+} from 'ethers';
+import type { AppConfig } from './contracts.js';
+import type { RelayPhase } from './flow.js';
+import { ProofWorkerClient } from './worker.js';
+
+export interface UsagePaymentEvidence {
+ orderId: string;
+ machineId: string;
+ payer: string;
+ beneficiary: string;
+ startTime: string;
+ duration: string;
+ amount: string;
+}
+
+export interface AttestcoinEvidence {
+ chainKey: number;
+ blockHeight: number;
+ encodedTransaction: string;
+ merkleRoot: string;
+ siblings: Array<{ hash: string; isLeft: boolean }>;
+ lowerEndpointDigest: string;
+ continuityRoots: string[];
+}
+
+export interface PublicProofEvidence {
+ schema: 'proofkey.public-proof.v1';
+ source: {
+ transactionHash: string;
+ blockNumber?: number;
+ payment?: UsagePaymentEvidence;
+ };
+ relay: {
+ phase: RelayPhase;
+ createdAt: string;
+ updatedAt: string;
+ attempts: Partial>;
+ failedAtPhase?: RelayPhase;
+ failure?: {
+ code: string;
+ message: string;
+ phase: RelayPhase;
+ retryable: boolean;
+ };
+ };
+ attestcoin?: AttestcoinEvidence;
+ creditcoin: {
+ transactionHash?: string;
+ queryId?: string;
+ accessExpiresAt?: string;
+ };
+}
+
+export type InvariantStatus = 'pass' | 'fail' | 'pending';
+
+export interface ProofInvariant {
+ key: string;
+ label: string;
+ status: InvariantStatus;
+ expected: string;
+ actual: string;
+ explanation: string;
+}
+
+export interface ProofChainSnapshot {
+ receiptStatus?: number;
+ sourceTo?: string;
+ sourceFrom?: string;
+ payment?: UsagePaymentEvidence;
+ machine?: {
+ owner: string;
+ tariff: string;
+ active: boolean;
+ };
+ orderProcessed?: boolean;
+ authorizationId?: string;
+ accessExpiresAt?: string;
+ queryId?: string;
+}
+
+export interface ProofRecord {
+ evidence: PublicProofEvidence;
+ chain: ProofChainSnapshot;
+ invariants: ProofInvariant[];
+}
+
+const usageInterface = new Interface([
+ 'event UsagePaid(bytes32 indexed orderId,bytes32 indexed machineId,address indexed payer,address beneficiary,uint64 startTime,uint64 duration,uint256 amount)',
+]);
+const activationInterface = new Interface([
+ 'event ProofKeyAccessActivated(bytes32 indexed queryId,bytes32 indexed orderId,bytes32 indexed machineId,address payer,uint64 expiresAt)',
+]);
+const machineAbi = [
+ 'function machines(bytes32 machineId) view returns (address owner,address controller,bytes32 metadataHash,uint128 tariff,bool active)',
+] as const;
+const proofKeyAbi = [
+ 'function processedOrders(bytes32 orderId) view returns (bool)',
+] as const;
+const accessPassAbi = [
+ 'function accessCredentials(bytes32 machineId,address beneficiary) view returns (bytes32 authorizationId,uint64 expiresAt)',
+] as const;
+
+export class ProofExplorerClient {
+ private readonly source: JsonRpcProvider;
+ private readonly creditcoin: JsonRpcProvider;
+ private readonly worker: ProofWorkerClient;
+
+ constructor(private readonly config: AppConfig) {
+ this.source = new JsonRpcProvider(config.sepoliaRpcUrl, 11155111, {
+ staticNetwork: true,
+ });
+ this.creditcoin = new JsonRpcProvider(config.creditcoinRpcUrl, 102031, {
+ staticNetwork: true,
+ });
+ this.worker = new ProofWorkerClient(config.workerUrl);
+ }
+
+ async load(identifier: string): Promise {
+ const evidence = await this.worker.getProof(identifier);
+ const [transaction, receipt] = await Promise.all([
+ this.source.getTransaction(evidence.source.transactionHash),
+ this.source.getTransactionReceipt(evidence.source.transactionHash),
+ ]);
+ const payment =
+ evidence.source.payment ??
+ (receipt
+ ? decodeUsagePayment(receipt, this.config.registryAddress)
+ : undefined);
+ const chain: ProofChainSnapshot = {
+ receiptStatus: receipt?.status ?? undefined,
+ sourceTo: transaction?.to ? getAddress(transaction.to) : undefined,
+ sourceFrom: transaction?.from ? getAddress(transaction.from) : undefined,
+ payment,
+ };
+
+ if (payment) {
+ const machine = new Contract(
+ this.config.machineRegistryAddress,
+ machineAbi,
+ this.creditcoin,
+ );
+ const proofKey = new Contract(
+ this.config.proofKeyAscAddress,
+ proofKeyAbi,
+ this.creditcoin,
+ );
+ const accessPass = new Contract(
+ this.config.accessPassAddress,
+ accessPassAbi,
+ this.creditcoin,
+ );
+ const [policy, processed, credential, activationReceipt] =
+ await Promise.all([
+ machine.getFunction('machines')(payment.machineId),
+ proofKey.getFunction('processedOrders')(payment.orderId),
+ accessPass.getFunction('accessCredentials')(
+ payment.machineId,
+ payment.payer,
+ ),
+ evidence.creditcoin.transactionHash
+ ? this.creditcoin.getTransactionReceipt(
+ evidence.creditcoin.transactionHash,
+ )
+ : Promise.resolve(null),
+ ]);
+ chain.machine = {
+ owner: getAddress(policy.owner as string),
+ tariff: (policy.tariff as bigint).toString(),
+ active: policy.active as boolean,
+ };
+ chain.orderProcessed = processed as boolean;
+ chain.authorizationId = credential.authorizationId as string;
+ chain.accessExpiresAt = (credential.expiresAt as bigint).toString();
+ chain.queryId =
+ evidence.creditcoin.queryId ??
+ (activationReceipt ? decodeQueryId(activationReceipt) : undefined);
+ }
+
+ const hydrated: PublicProofEvidence = {
+ ...evidence,
+ source: { ...evidence.source, payment },
+ creditcoin: {
+ ...evidence.creditcoin,
+ queryId: evidence.creditcoin.queryId ?? chain.queryId,
+ },
+ };
+ return {
+ evidence: hydrated,
+ chain,
+ invariants: computeProofInvariants(hydrated, chain, this.config),
+ };
+ }
+}
+
+export function computeProofInvariants(
+ evidence: PublicProofEvidence,
+ chain: ProofChainSnapshot,
+ config: Pick,
+): ProofInvariant[] {
+ const payment = chain.payment ?? evidence.source.payment;
+ const proof = evidence.attestcoin;
+ const invariant = (
+ key: string,
+ label: string,
+ expected: string | undefined,
+ actual: string | undefined,
+ passes: boolean | undefined,
+ explanation: string,
+ ): ProofInvariant => ({
+ key,
+ label,
+ expected: expected ?? 'Waiting for policy data',
+ actual: actual ?? 'Waiting for evidence',
+ status: passes === undefined ? 'pending' : passes ? 'pass' : 'fail',
+ explanation,
+ });
+ const expectedAmount =
+ payment && chain.machine
+ ? (BigInt(chain.machine.tariff) * BigInt(payment.duration)).toString()
+ : undefined;
+ const computedExpiry = payment
+ ? (BigInt(payment.startTime) + BigInt(payment.duration)).toString()
+ : undefined;
+
+ return [
+ invariant(
+ 'receipt',
+ 'Source receipt succeeded',
+ 'status = 1',
+ chain.receiptStatus === undefined
+ ? undefined
+ : `status = ${chain.receiptStatus}`,
+ chain.receiptStatus === undefined ? undefined : chain.receiptStatus === 1,
+ 'A reverted or missing Sepolia receipt cannot authorize a machine.',
+ ),
+ invariant(
+ 'source-contract',
+ 'Trusted payment registry',
+ getAddress(config.registryAddress),
+ chain.sourceTo,
+ chain.sourceTo
+ ? chain.sourceTo.toLowerCase() === config.registryAddress.toLowerCase()
+ : undefined,
+ 'The proven transaction must target ProofKey’s configured Sepolia registry.',
+ ),
+ invariant(
+ 'chain-key',
+ 'Attestcoin source chain',
+ 'chain key 1 (Ethereum Sepolia)',
+ proof ? `chain key ${proof.chainKey}` : undefined,
+ proof ? proof.chainKey === 1 : undefined,
+ 'ProofKeyASC rejects proofs from any other Attestcoin chain key.',
+ ),
+ invariant(
+ 'covered-block',
+ 'Covered canonical block',
+ evidence.source.blockNumber?.toString() ?? 'confirmed source block',
+ proof?.blockHeight.toString(),
+ proof && evidence.source.blockNumber !== undefined
+ ? proof.blockHeight === evidence.source.blockNumber
+ : undefined,
+ 'The proof-builder height must be the exact block containing UsagePaid.',
+ ),
+ invariant(
+ 'payer',
+ 'Payer matches transaction sender',
+ chain.sourceFrom ?? 'source transaction sender',
+ payment?.payer,
+ payment && chain.sourceFrom
+ ? payment.payer.toLowerCase() === chain.sourceFrom.toLowerCase()
+ : undefined,
+ 'This prevents a proof from granting access to a substituted payer.',
+ ),
+ invariant(
+ 'beneficiary',
+ 'Beneficiary matches machine owner',
+ chain.machine?.owner ?? 'live Creditcoin machine owner',
+ payment?.beneficiary,
+ payment && chain.machine
+ ? payment.beneficiary.toLowerCase() ===
+ chain.machine.owner.toLowerCase()
+ : undefined,
+ 'The Sepolia recipient must equal the owner currently registered on Creditcoin.',
+ ),
+ invariant(
+ 'machine-active',
+ 'Machine policy is active',
+ 'active = true',
+ chain.machine ? `active = ${chain.machine.active}` : undefined,
+ chain.machine?.active,
+ 'Inactive or unknown machines fail closed.',
+ ),
+ invariant(
+ 'tariff',
+ 'Payment matches live tariff',
+ expectedAmount,
+ payment?.amount,
+ expectedAmount && payment
+ ? BigInt(payment.amount) === BigInt(expectedAmount)
+ : undefined,
+ 'Amount must equal Creditcoin tariff multiplied by proven duration.',
+ ),
+ invariant(
+ 'duration',
+ 'Duration is policy bounded',
+ '1–2,592,000 seconds',
+ payment ? `${payment.duration} seconds` : undefined,
+ payment
+ ? BigInt(payment.duration) > 0n &&
+ BigInt(payment.duration) <= 2_592_000n
+ : undefined,
+ 'ProofKey limits access grants to a maximum of 30 days.',
+ ),
+ invariant(
+ 'expiry',
+ 'Access expiry is deterministic',
+ computedExpiry,
+ chain.accessExpiresAt ?? evidence.creditcoin.accessExpiresAt,
+ computedExpiry &&
+ (chain.accessExpiresAt ?? evidence.creditcoin.accessExpiresAt)
+ ? computedExpiry ===
+ (chain.accessExpiresAt ?? evidence.creditcoin.accessExpiresAt)
+ : undefined,
+ 'Expiry is derived only from the proven start time and duration.',
+ ),
+ invariant(
+ 'replay',
+ 'Order replay guard',
+ ['completed', 'duplicate'].includes(evidence.relay.phase)
+ ? 'processed = true'
+ : 'processed after execution',
+ chain.orderProcessed === undefined
+ ? undefined
+ : `processed = ${chain.orderProcessed}`,
+ chain.orderProcessed === undefined
+ ? undefined
+ : ['completed', 'duplicate'].includes(evidence.relay.phase)
+ ? chain.orderProcessed
+ : undefined,
+ 'Each order and Attestcoin query can activate access only once.',
+ ),
+ ];
+}
+
+export function assertSafePublicEvidence(value: unknown, path = '$'): void {
+ const blocked =
+ /^(?:api[-_]?key|database[-_]?url|deployer[-_]?private[-_]?key|mnemonic|password|private[-_]?key|rpc[-_]?url|secret|worker[-_]?private[-_]?key|internal[-_]?path)$/i;
+ if (Array.isArray(value)) {
+ value.forEach((entry, index) =>
+ assertSafePublicEvidence(entry, `${path}[${index}]`),
+ );
+ return;
+ }
+ if (!value || typeof value !== 'object') return;
+ for (const [key, entry] of Object.entries(value)) {
+ if (blocked.test(key))
+ throw new Error(`Unsafe public evidence field ${path}.${key}.`);
+ assertSafePublicEvidence(entry, `${path}.${key}`);
+ }
+}
+
+export function downloadPublicEvidence(evidence: PublicProofEvidence): void {
+ assertSafePublicEvidence(evidence);
+ const blob = new Blob([`${JSON.stringify(evidence, null, 2)}\n`], {
+ type: 'application/json',
+ });
+ const anchor = document.createElement('a');
+ anchor.href = URL.createObjectURL(blob);
+ anchor.download = `proofkey-proof-${evidence.source.transactionHash}.json`;
+ anchor.click();
+ URL.revokeObjectURL(anchor.href);
+}
+
+function decodeUsagePayment(
+ receipt: TransactionReceipt,
+ registryAddress: string,
+): UsagePaymentEvidence | undefined {
+ for (const log of receipt.logs) {
+ if (log.address.toLowerCase() !== registryAddress.toLowerCase()) continue;
+ try {
+ const event = usageInterface.parseLog(log);
+ if (event?.name !== 'UsagePaid') continue;
+ return {
+ orderId: event.args.orderId as string,
+ machineId: event.args.machineId as string,
+ payer: getAddress(event.args.payer as string),
+ beneficiary: getAddress(event.args.beneficiary as string),
+ startTime: (event.args.startTime as bigint).toString(),
+ duration: (event.args.duration as bigint).toString(),
+ amount: (event.args.amount as bigint).toString(),
+ };
+ } catch {
+ // Ignore unrelated source logs.
+ }
+ }
+ return undefined;
+}
+
+function decodeQueryId(receipt: TransactionReceipt): string | undefined {
+ for (const log of receipt.logs) {
+ try {
+ const event = activationInterface.parseLog(log);
+ if (event?.name === 'ProofKeyAccessActivated')
+ return event.args.queryId as string;
+ } catch {
+ // Ignore verifier and AccessPass logs.
+ }
+ }
+ return undefined;
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 8716ce1..84e739f 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -2550,6 +2550,424 @@ input:focus-visible {
opacity: 1;
}
}
+.proof-search-scopes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin: 10px 4px 0;
+}
+.proof-search-scopes span {
+ padding: 6px 9px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--muted);
+ font-family: 'DM Mono', monospace;
+ font-size: 7px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.proof-empty-callout {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ max-width: 620px;
+ margin: 18px auto 0;
+ padding: 17px 20px;
+ border: 1px solid rgba(38, 128, 79, 0.2);
+ border-radius: 15px;
+ background: rgba(171, 255, 47, 0.08);
+}
+.proof-empty-callout svg {
+ flex: 0 0 auto;
+ color: var(--forest);
+}
+.proof-empty-callout strong {
+ font-size: 10px;
+}
+.proof-empty-callout p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.55;
+}
+.proof-verdict {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 9px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 999px;
+ font-family: 'DM Mono', monospace;
+ font-size: 8px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.proof-verdict.verified {
+ border-color: rgba(172, 255, 47, 0.35);
+ background: rgba(172, 255, 47, 0.1);
+ color: var(--lime);
+}
+.proof-verdict.duplicate {
+ border-color: rgba(113, 195, 255, 0.35);
+ background: rgba(113, 195, 255, 0.1);
+ color: #8ecfff;
+}
+.proof-verdict.failed {
+ border-color: rgba(255, 104, 71, 0.35);
+ background: rgba(255, 104, 71, 0.1);
+ color: #ff8b72;
+}
+.proof-verdict.pending {
+ color: #f0c56b;
+}
+.proof-timeline {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ padding: 0 35px 30px;
+}
+.proof-timeline > div {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: #647168;
+}
+.proof-timeline > div::after {
+ position: absolute;
+ z-index: 0;
+ top: 14px;
+ right: 8px;
+ left: 38px;
+ height: 1px;
+ background: rgba(255, 255, 255, 0.11);
+ content: '';
+}
+.proof-timeline > div:last-child::after {
+ display: none;
+}
+.proof-timeline > div > span {
+ z-index: 1;
+ display: grid;
+ width: 29px;
+ height: 29px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ background: var(--forest-dark);
+ font-family: 'DM Mono', monospace;
+ font-size: 8px;
+}
+.proof-timeline small {
+ position: relative;
+ z-index: 1;
+ padding-right: 8px;
+ background: var(--forest-dark);
+ font-size: 8px;
+}
+.proof-timeline .complete {
+ color: var(--lime);
+}
+.proof-timeline .current {
+ color: #f0c56b;
+}
+.proof-timeline .failed {
+ color: #ff8b72;
+}
+.proof-chain-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ padding: 1px;
+ background: rgba(255, 255, 255, 0.1);
+}
+.proof-chain-grid article {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ min-height: 185px;
+ flex-direction: column;
+ padding: 25px;
+ background: #17382d;
+}
+.proof-chain-grid article > span,
+.proof-section-heading span {
+ color: var(--lime);
+ font-family: 'DM Mono', monospace;
+ font-size: 7px;
+ letter-spacing: 0.13em;
+}
+.proof-chain-grid h3 {
+ margin: 25px 0 8px;
+ font-size: 16px;
+}
+.proof-chain-grid code {
+ overflow: hidden;
+ color: #c5d0c9;
+ font-size: 9px;
+ text-overflow: ellipsis;
+}
+.proof-chain-grid small {
+ margin-top: 9px;
+ color: #74847a;
+ font-size: 8px;
+}
+.proof-chain-grid a {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: auto;
+ color: var(--lime);
+ font-size: 8px;
+ text-decoration: none;
+}
+.proof-section-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 25px;
+ padding: 34px 35px 20px;
+ background: var(--paper);
+ color: var(--ink);
+}
+.proof-section-heading h2 {
+ margin: 7px 0 0;
+ font-size: 23px;
+ letter-spacing: -0.04em;
+}
+.proof-section-heading > p {
+ max-width: 440px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.6;
+}
+.invariant-table {
+ padding: 0 35px 35px;
+ background: var(--paper);
+ color: var(--ink);
+}
+.invariant-row {
+ display: grid;
+ grid-template-columns: minmax(240px, 1.4fr) 1fr 1fr 70px;
+ align-items: center;
+ gap: 18px;
+ min-height: 70px;
+ border-bottom: 1px solid var(--line);
+}
+.invariant-row > span:first-child {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 11px;
+}
+.invariant-row i {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ flex: 0 0 auto;
+ place-items: center;
+ border-radius: 50%;
+ background: #e5e8e5;
+ color: #778079;
+}
+.invariant-row.pass i {
+ background: rgba(76, 190, 112, 0.14);
+ color: #288654;
+}
+.invariant-row.fail i {
+ background: rgba(255, 104, 71, 0.14);
+ color: #c44e38;
+}
+.invariant-row strong,
+.invariant-row small {
+ display: block;
+}
+.invariant-row strong {
+ font-size: 10px;
+}
+.invariant-row small {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 7px;
+ line-height: 1.45;
+}
+.invariant-row code {
+ overflow: hidden;
+ color: #536058;
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.invariant-row > b {
+ justify-self: end;
+ padding: 5px 8px;
+ border-radius: 999px;
+ background: #e8eae8;
+ color: #69736c;
+ font-family: 'DM Mono', monospace;
+ font-size: 7px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.invariant-row.pass > b {
+ background: rgba(76, 190, 112, 0.13);
+ color: #23774a;
+}
+.invariant-row.fail > b {
+ background: rgba(255, 104, 71, 0.13);
+ color: #aa402d;
+}
+.invariant-header {
+ min-height: 35px;
+ color: #939b96;
+ font-family: 'DM Mono', monospace;
+ font-size: 7px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.invariant-header span:last-child {
+ justify-self: end;
+}
+.proof-evidence-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ background: var(--line);
+ color: var(--ink);
+}
+.proof-evidence-card {
+ padding: 0 35px 32px;
+ background: #f1f0e9;
+}
+.proof-section-heading.compact-heading {
+ padding: 30px 0 12px;
+ background: transparent;
+}
+.proof-detail-list {
+ margin: 0;
+}
+.proof-detail-list > div {
+ display: grid;
+ grid-template-columns: 120px minmax(0, 1fr);
+ gap: 15px;
+ padding: 11px 0;
+ border-bottom: 1px solid var(--line);
+}
+.proof-detail-list dt {
+ color: var(--muted);
+ font-size: 8px;
+}
+.proof-detail-list dd {
+ overflow: hidden;
+ margin: 0;
+ font-family: 'DM Mono', monospace;
+ font-size: 8px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.proof-evidence-card .text-link {
+ margin-top: 18px;
+}
+.proof-failure {
+ display: flex;
+ gap: 14px;
+ padding: 22px 35px;
+ border-top: 1px solid rgba(255, 104, 71, 0.25);
+ background: #391e19;
+ color: #ff9b86;
+}
+.proof-failure p {
+ margin: 4px 0;
+ font-size: 9px;
+}
+.proof-failure small {
+ color: #bc7768;
+ font-size: 8px;
+}
+.trust-boundary {
+ background: #0d2a21;
+}
+.trust-boundary .proof-section-heading {
+ padding-bottom: 12px;
+ background: transparent;
+ color: #fff;
+}
+.trust-boundary > div:last-child {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ padding: 0 35px 35px;
+}
+.trust-boundary article {
+ padding: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ border-radius: 13px;
+ background: rgba(255, 255, 255, 0.025);
+}
+.trust-boundary svg {
+ display: block;
+ margin-bottom: 22px;
+ color: var(--lime);
+}
+.trust-boundary strong {
+ font-size: 10px;
+}
+.trust-boundary p {
+ margin: 7px 0 0;
+ color: #839188;
+ font-size: 8px;
+ line-height: 1.6;
+}
+.proof-export-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 22px 35px;
+ background: #091f18;
+}
+.proof-export-bar > div,
+.proof-export-bar > div > span {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.proof-export-bar > div > span {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 3px;
+}
+.proof-export-bar svg {
+ color: var(--lime);
+}
+.proof-export-bar strong,
+.proof-export-bar small {
+ display: block;
+}
+.proof-export-bar strong {
+ font-size: 9px;
+}
+.proof-export-bar small {
+ color: #718078;
+ font-size: 8px;
+}
+.proof-export-bar button {
+ display: inline-flex;
+ min-height: 39px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 14px;
+ border: 1px solid rgba(172, 255, 47, 0.25);
+ border-radius: 10px;
+ background: rgba(172, 255, 47, 0.09);
+ color: var(--lime);
+ cursor: pointer;
+ font-size: 8px;
+ font-weight: 800;
+}
+
@keyframes page-in {
from {
transform: translateY(8px);
@@ -2643,6 +3061,17 @@ input:focus-visible {
.checkout-summary .machine-visual {
max-height: 380px;
}
+ .proof-chain-grid,
+ .trust-boundary > div:last-child {
+ grid-template-columns: 1fr;
+ }
+ .proof-chain-grid article {
+ min-height: 150px;
+ }
+ .invariant-row {
+ grid-template-columns: minmax(210px, 1.3fr) 1fr 1fr 60px;
+ gap: 12px;
+ }
}
@media (max-width: 760px) {
@@ -2789,6 +3218,73 @@ input:focus-visible {
.proof-score {
text-align: left;
}
+ .proof-record-head,
+ .proof-section-heading,
+ .proof-export-bar {
+ padding-right: 22px;
+ padding-left: 22px;
+ }
+ .proof-timeline {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ padding: 0 22px 25px;
+ }
+ .proof-timeline > div::after {
+ top: 30px;
+ right: auto;
+ bottom: -8px;
+ left: 14px;
+ width: 1px;
+ height: auto;
+ }
+ .proof-timeline small {
+ background: transparent;
+ }
+ .proof-section-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .invariant-table {
+ padding: 0 22px 25px;
+ }
+ .invariant-header {
+ display: none;
+ }
+ .invariant-row {
+ grid-template-columns: 1fr auto;
+ gap: 9px;
+ padding: 16px 0;
+ }
+ .invariant-row > span:first-child {
+ grid-column: 1;
+ }
+ .invariant-row code {
+ grid-column: 1 / -1;
+ padding-left: 35px;
+ }
+ .invariant-row > b {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ .proof-evidence-grid {
+ grid-template-columns: 1fr;
+ }
+ .proof-evidence-card {
+ padding-right: 22px;
+ padding-left: 22px;
+ }
+ .trust-boundary > div:last-child {
+ padding-right: 22px;
+ padding-left: 22px;
+ }
+ .proof-export-bar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .proof-export-bar button {
+ justify-content: center;
+ width: 100%;
+ }
.device-lock {
min-height: 460px;
padding: 35px 20px;
diff --git a/apps/web/src/worker.ts b/apps/web/src/worker.ts
index 6bf6106..5024be6 100644
--- a/apps/web/src/worker.ts
+++ b/apps/web/src/worker.ts
@@ -1,4 +1,14 @@
import type { RelayJob } from './flow.js';
+import type { PublicProofEvidence } from './proof.js';
+
+export class ProofLookupError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ }
+}
export function normalizeProofWorkerUrl(
value: string | undefined,
@@ -43,6 +53,24 @@ export class ProofWorkerClient {
return this.parse(response);
}
+ async getProof(identifier: string): Promise {
+ const response = await fetch(`${this.baseUrl}/proofs/${identifier}`);
+ if (response.status === 404)
+ throw new ProofLookupError(
+ 'No proof matches that source transaction, order, query, or Creditcoin transaction.',
+ 404,
+ );
+ if (!response.ok) {
+ const body = (await response.json().catch(() => undefined)) as
+ { error?: { message?: string } } | undefined;
+ throw new ProofLookupError(
+ body?.error?.message ?? `Proof relay returned HTTP ${response.status}.`,
+ response.status,
+ );
+ }
+ return (await response.json()) as PublicProofEvidence;
+ }
+
async waitForCompletion(
transactionHash: string,
onUpdate: (job: RelayJob) => void,
diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json
index 13027dc..72b5e82 100644
--- a/apps/web/tsconfig.test.json
+++ b/apps/web/tsconfig.test.json
@@ -23,6 +23,8 @@
"src/contracts.ts",
"src/vite-env.d.ts",
"src/activity.ts",
- "src/activity.test.ts"
+ "src/activity.test.ts",
+ "src/proof.ts",
+ "src/proof.test.ts"
]
}
diff --git a/apps/worker/README.md b/apps/worker/README.md
index c4852be..c0faa18 100644
--- a/apps/worker/README.md
+++ b/apps/worker/README.md
@@ -10,6 +10,12 @@ The relay exposes a small HTTPS ingestion/status API and runs proof execution in
| `GET /ready` | Separate database, Sepolia RPC, Creditcoin RPC, and relayer-balance checks. |
| `POST /jobs` | Idempotently enqueue `{ "transactionHash": "0x…" }`. |
| `GET /jobs/:transactionHash` | Read the public proof phase, result, or structured failure. |
+| `GET /proofs/:identifier` | Search by source transaction, order, query, or CC3 transaction. |
+
+Proof responses contain only public receipt, Attestcoin, relay-phase, and
+Creditcoin execution fields. Every response passes a recursive secret-field
+guard before serialization; RPC URLs, private keys, and internal paths are
+never part of the public schema.
Only browser origins listed in `FRONTEND_ORIGINS` receive CORS access. Enqueue requests are bounded per client by `RELAY_RATE_LIMIT_REQUESTS` and `RELAY_RATE_LIMIT_WINDOW_MS`.
diff --git a/apps/worker/src/adapter.ts b/apps/worker/src/adapter.ts
index 6e7d113..7756d4f 100644
--- a/apps/worker/src/adapter.ts
+++ b/apps/worker/src/adapter.ts
@@ -12,6 +12,7 @@ import type { WorkerConfig } from './config.js';
import { PermanentRelayError } from './retry.js';
import type {
AttestcoinProof,
+ CreditcoinExecution,
RelayAdapter,
SourceReceipt,
UsagePayment,
@@ -24,6 +25,9 @@ const proofKeyAbi = [
'function execute(uint8 action,uint64 chainKey,uint64 blockHeight,bytes encodedTransaction,bytes32 merkleRoot,tuple(bytes32 hash,bool isLeft)[] siblings,bytes32 lowerEndpointDigest,bytes32[] continuityRoots) returns (bool)',
'function processedOrders(bytes32 orderId) view returns (bool)',
] as const;
+const activationInterface = new Interface([
+ 'event ProofKeyAccessActivated(bytes32 indexed queryId,bytes32 indexed orderId,bytes32 indexed machineId,address payer,uint64 expiresAt)',
+]);
export class NetworkRelayAdapter implements RelayAdapter {
private readonly sourceProvider: JsonRpcProvider;
@@ -193,7 +197,7 @@ export class NetworkRelayAdapter implements RelayAdapter {
)) as boolean;
}
- async submitProof(proof: AttestcoinProof): Promise {
+ async submitProof(proof: AttestcoinProof): Promise {
const transaction = await this.contract.getFunction('execute')(
0,
proof.chainKey,
@@ -207,7 +211,19 @@ export class NetworkRelayAdapter implements RelayAdapter {
const receipt = await transaction.wait();
if (!receipt || receipt.status !== 1)
throw new Error('Creditcoin proof transaction reverted.');
- return transaction.hash as string;
+ for (const log of receipt.logs) {
+ if (
+ getAddress(log.address) !== getAddress(this.config.proofKeyAscAddress)
+ )
+ continue;
+ const event = activationInterface.parseLog(log);
+ if (event?.name === 'ProofKeyAccessActivated')
+ return {
+ transactionHash: transaction.hash as string,
+ queryId: event.args.queryId as string,
+ };
+ }
+ throw new Error('Creditcoin execution omitted ProofKeyAccessActivated.');
}
private parseUsagePayment(receipt: TransactionReceipt): UsagePayment {
diff --git a/apps/worker/src/evidence.test.ts b/apps/worker/src/evidence.test.ts
index 46252bf..54fc7d6 100644
--- a/apps/worker/src/evidence.test.ts
+++ b/apps/worker/src/evidence.test.ts
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
-import { assertPublicEvidence, writePublicEvidence } from './evidence.js';
+import {
+ assertPublicEvidence,
+ publicEvidenceFromJob,
+ writePublicEvidence,
+} from './evidence.js';
test('accepts public chain evidence and payment-token fields', () => {
assert.doesNotThrow(() =>
@@ -25,6 +29,10 @@ test('rejects nested secret and RPC fields', () => {
() => assertPublicEvidence({ workerPrivateKey: `0x${'ab'.repeat(32)}` }),
/Refusing to persist secret-bearing field/,
);
+ assert.throws(
+ () => assertPublicEvidence({ relay: { internalPath: '/srv/proof.json' } }),
+ /Refusing to persist secret-bearing field/,
+ );
});
test('writes labeled public evidence without secret material', async (context) => {
@@ -40,3 +48,36 @@ test('writes labeled public evidence without secret material', async (context) =
assert.deepEqual(JSON.parse(await readFile(outputPath, 'utf8')), evidence);
});
+
+test('builds a recursively safe public proof record from a relay job', () => {
+ const evidence = publicEvidenceFromJob({
+ sourceTransactionHash: `0x${'ab'.repeat(32)}`,
+ phase: 'completed',
+ createdAt: '2026-09-13T10:00:00.000Z',
+ updatedAt: '2026-09-13T10:05:00.000Z',
+ attempts: { proof_generation: 1 },
+ sourceBlockNumber: 123,
+ sourcePayment: {
+ orderId: `0x${'01'.repeat(32)}`,
+ machineId: `0x${'02'.repeat(32)}`,
+ payer: '0x1111111111111111111111111111111111111111',
+ beneficiary: '0x2222222222222222222222222222222222222222',
+ startTime: '1000',
+ duration: '60',
+ amount: '600',
+ },
+ proof: {
+ chainKey: 1,
+ blockHeight: 123,
+ encodedTransaction: '0x01',
+ merkleRoot: `0x${'03'.repeat(32)}`,
+ siblings: [],
+ lowerEndpointDigest: `0x${'04'.repeat(32)}`,
+ continuityRoots: [],
+ },
+ });
+ assert.equal(evidence.schema, 'proofkey.public-proof.v1');
+ assert.equal(evidence.source.payment?.amount, '600');
+ assert.doesNotThrow(() => assertPublicEvidence(evidence));
+ assert.equal(JSON.stringify(evidence).includes('rpcUrl'), false);
+});
diff --git a/apps/worker/src/evidence.ts b/apps/worker/src/evidence.ts
index 5706ac2..50e4ff2 100644
--- a/apps/worker/src/evidence.ts
+++ b/apps/worker/src/evidence.ts
@@ -1,8 +1,9 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
+import type { PublicProofEvidence, RelayJob } from './types.js';
const secretFieldPattern =
- /^(?:api[-_]?key|deployer[-_]?private[-_]?key|mnemonic|password|private[-_]?key|rpc[-_]?url|secret|worker[-_]?private[-_]?key)$/i;
+ /^(?:api[-_]?key|database[-_]?url|deployer[-_]?private[-_]?key|internal[-_]?path|mnemonic|password|private[-_]?key|rpc[-_]?url|secret|worker[-_]?private[-_]?key)$/i;
export function assertPublicEvidence(value: unknown, path = '$'): void {
if (Array.isArray(value)) {
@@ -31,3 +32,30 @@ export async function writePublicEvidence(
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
+
+export function publicEvidenceFromJob(job: RelayJob): PublicProofEvidence {
+ const evidence: PublicProofEvidence = {
+ schema: 'proofkey.public-proof.v1',
+ source: {
+ transactionHash: job.sourceTransactionHash,
+ blockNumber: job.sourceBlockNumber,
+ payment: job.sourcePayment,
+ },
+ relay: {
+ phase: job.phase,
+ createdAt: job.createdAt,
+ updatedAt: job.updatedAt,
+ attempts: job.attempts,
+ failedAtPhase: job.failedAtPhase,
+ failure: job.failure,
+ },
+ attestcoin: job.proof,
+ creditcoin: {
+ transactionHash: job.creditcoinTransactionHash,
+ queryId: job.queryId,
+ accessExpiresAt: job.accessExpiresAt,
+ },
+ };
+ assertPublicEvidence(evidence);
+ return evidence;
+}
diff --git a/apps/worker/src/http.test.ts b/apps/worker/src/http.test.ts
index 599cc09..796e834 100644
--- a/apps/worker/src/http.test.ts
+++ b/apps/worker/src/http.test.ts
@@ -41,6 +41,10 @@ const queue: JobQueue = {
transactionHash.toLowerCase() === hash
? { ...job, phase: 'proof_generation' }
: undefined,
+ find: async (identifier) =>
+ identifier.toLowerCase() === hash
+ ? { ...job, phase: 'proof_generation' }
+ : undefined,
};
async function start(
@@ -79,6 +83,20 @@ test('accepts an allowed browser job and exposes status and readiness', async (c
const readiness = await fetch(`${url}/ready`);
assert.equal(readiness.status, 200);
assert.deepEqual(await readiness.json(), ready);
+
+ const proof = await fetch(`${url}/proofs/${hash}`);
+ assert.equal(proof.status, 200);
+ assert.deepEqual(await proof.json(), {
+ schema: 'proofkey.public-proof.v1',
+ source: { transactionHash: hash },
+ relay: {
+ phase: 'proof_generation',
+ createdAt: job.createdAt,
+ updatedAt: job.updatedAt,
+ attempts: {},
+ },
+ creditcoin: {},
+ });
});
test('rejects an untrusted browser origin without a CORS grant', async (context) => {
diff --git a/apps/worker/src/http.ts b/apps/worker/src/http.ts
index 8ad5087..25eb6a6 100644
--- a/apps/worker/src/http.ts
+++ b/apps/worker/src/http.ts
@@ -5,6 +5,7 @@ import {
type ServerResponse,
} from 'node:http';
import { PermanentRelayError } from './retry.js';
+import { publicEvidenceFromJob } from './evidence.js';
import type { JobQueue } from './queue.js';
import type { RelayReadiness } from './types.js';
@@ -117,6 +118,19 @@ export function createRelayHttpServer(
false,
);
}
+ const proofMatch = /^\/proofs\/(0x[0-9a-fA-F]{64})$/.exec(url.pathname);
+ if (request.method === 'GET' && proofMatch?.[1]) {
+ const job = await queue.find(proofMatch[1]);
+ return job
+ ? send(response, 200, publicEvidenceFromJob(job))
+ : sendError(
+ response,
+ 404,
+ 'PROOF_NOT_FOUND',
+ 'No proof matches that source transaction, order, query, or Creditcoin transaction.',
+ false,
+ );
+ }
return sendError(
response,
404,
diff --git a/apps/worker/src/live-mvp.ts b/apps/worker/src/live-mvp.ts
index b45f4ae..7da177c 100644
--- a/apps/worker/src/live-mvp.ts
+++ b/apps/worker/src/live-mvp.ts
@@ -288,7 +288,8 @@ async function main(): Promise {
'creditcoin_execution',
'Proof generated; submitting ProofKeyASC.execute.',
);
- const creditcoinTransactionHash = await adapter.submitProof(proof);
+ const execution = await adapter.submitProof(proof);
+ const creditcoinTransactionHash = execution.transactionHash;
const creditcoinReceipt = await creditcoinProvider.getTransactionReceipt(
creditcoinTransactionHash,
);
diff --git a/apps/worker/src/queue.test.ts b/apps/worker/src/queue.test.ts
index e2a2e89..fba97b0 100644
--- a/apps/worker/src/queue.test.ts
+++ b/apps/worker/src/queue.test.ts
@@ -27,3 +27,16 @@ test('rejects malformed public job input before persistence', async () => {
);
assert.equal(store.jobs.size, 0);
});
+
+test('finds proof jobs by cross-chain identifiers', async () => {
+ const store = new MemoryDurableStore();
+ const queue = new RelayQueue(store);
+ const first = await queue.enqueue(hash);
+ const orderId = `0x${'12'.repeat(32)}`;
+ const queryId = `0x${'34'.repeat(32)}`;
+ const creditcoinTransactionHash = `0x${'56'.repeat(32)}`;
+ await store.save({ ...first, orderId, queryId, creditcoinTransactionHash });
+
+ for (const identifier of [hash, orderId, queryId, creditcoinTransactionHash])
+ assert.equal((await queue.find(identifier))?.sourceTransactionHash, hash);
+});
diff --git a/apps/worker/src/queue.ts b/apps/worker/src/queue.ts
index f7f4dab..b11f3af 100644
--- a/apps/worker/src/queue.ts
+++ b/apps/worker/src/queue.ts
@@ -4,6 +4,7 @@ import type { DurableJobStore, RelayJob } from './types.js';
export interface JobQueue {
enqueue(transactionHash: string): Promise;
get(transactionHash: string): Promise;
+ find(identifier: string): Promise;
}
const transactionHashPattern = /^0x[0-9a-fA-F]{64}$/;
@@ -36,4 +37,8 @@ export class RelayQueue implements JobQueue {
get(transactionHash: string): Promise {
return this.store.get(transactionHash.toLowerCase());
}
+
+ find(identifier: string): Promise {
+ return this.store.find(identifier.toLowerCase());
+ }
}
diff --git a/apps/worker/src/relay.test.ts b/apps/worker/src/relay.test.ts
index 451dbb4..5e081dd 100644
--- a/apps/worker/src/relay.test.ts
+++ b/apps/worker/src/relay.test.ts
@@ -78,11 +78,14 @@ class FakeAdapter implements RelayAdapter {
async isOrderProcessed(): Promise {
return this.processed;
}
- async submitProof(): Promise {
+ async submitProof() {
this.submissions += 1;
if (this.submissions <= this.submitFailures)
throw new Error('temporary Creditcoin RPC failure');
- return `0x${'cd'.repeat(32)}`;
+ return {
+ transactionHash: `0x${'cd'.repeat(32)}`,
+ queryId: `0x${'ef'.repeat(32)}`,
+ };
}
}
@@ -107,6 +110,9 @@ test('processes a UsagePaid transaction through every observable phase', async (
assert.equal(result.sourceBlockNumber, sourceReceipt.blockNumber);
assert.equal(result.accessExpiresAt, '1720003600');
assert.equal(result.creditcoinTransactionHash, `0x${'cd'.repeat(32)}`);
+ assert.equal(result.queryId, `0x${'ef'.repeat(32)}`);
+ assert.deepEqual(result.sourcePayment, sourceReceipt.payment);
+ assert.deepEqual(result.proof, proof);
const transitions = reporter.statuses
.filter((status) => status.attempt === undefined)
.map((status) => status.phase);
diff --git a/apps/worker/src/relay.ts b/apps/worker/src/relay.ts
index 0939eb9..9c2fcf4 100644
--- a/apps/worker/src/relay.ts
+++ b/apps/worker/src/relay.ts
@@ -73,6 +73,7 @@ export class ProofRelay {
job = {
...job,
sourceBlockNumber: receipt.blockNumber,
+ sourcePayment: receipt.payment,
orderId: receipt.payment.orderId,
machineId: receipt.payment.machineId,
payer: receipt.payment.payer,
@@ -118,6 +119,8 @@ export class ProofRelay {
`Proof identifies chain ${proof.chainKey}, block ${proof.blockHeight}; expected Sepolia chain key 1, block ${receipt.blockNumber}.`,
);
}
+ job = { ...job, proof };
+ await this.store.save(job);
activePhase = 'creditcoin_execution';
job = await this.transition(
@@ -125,9 +128,9 @@ export class ProofRelay {
activePhase,
'Submitting the proof to ProofKeyASC on Creditcoin.',
);
- let creditcoinTransactionHash: string;
+ let execution;
try {
- creditcoinTransactionHash = await this.runPhase(job, activePhase, () =>
+ execution = await this.runPhase(job, activePhase, () =>
this.adapter.submitProof(proof),
);
} catch (error) {
@@ -141,7 +144,11 @@ export class ProofRelay {
throw error;
}
- job = { ...job, creditcoinTransactionHash };
+ job = {
+ ...job,
+ creditcoinTransactionHash: execution.transactionHash,
+ queryId: execution.queryId,
+ };
return this.transition(
job,
'completed',
diff --git a/apps/worker/src/store.ts b/apps/worker/src/store.ts
index 121e4a1..992ede0 100644
--- a/apps/worker/src/store.ts
+++ b/apps/worker/src/store.ts
@@ -66,6 +66,22 @@ export class PostgresJobStore implements DurableJobStore {
return { job: existing, created: false };
}
+ async find(identifier: string): Promise {
+ await this.initialize();
+ const normalized = identifier.toLowerCase();
+ const result = await this.pool.query<{ job: RelayJob }>(
+ `SELECT job FROM ${this.table}
+ WHERE source_transaction_hash = $1
+ OR LOWER(job->>'orderId') = $1
+ OR LOWER(job->>'queryId') = $1
+ OR LOWER(job->>'creditcoinTransactionHash') = $1
+ ORDER BY updated_at DESC
+ LIMIT 1`,
+ [normalized],
+ );
+ return result.rows[0]?.job;
+ }
+
async save(job: RelayJob): Promise {
await this.initialize();
await this.pool.query(
diff --git a/apps/worker/src/test-store.ts b/apps/worker/src/test-store.ts
index d0e6c32..f7d7e1c 100644
--- a/apps/worker/src/test-store.ts
+++ b/apps/worker/src/test-store.ts
@@ -16,6 +16,22 @@ export class MemoryDurableStore implements DurableJobStore {
return { job: structuredClone(job), created: true };
}
+ async find(identifier: string): Promise {
+ const normalized = identifier.toLowerCase();
+ for (const job of this.jobs.values()) {
+ if (
+ [
+ job.sourceTransactionHash,
+ job.orderId,
+ job.queryId,
+ job.creditcoinTransactionHash,
+ ].some((value) => value?.toLowerCase() === normalized)
+ )
+ return structuredClone(job);
+ }
+ return undefined;
+ }
+
async save(job: RelayJob): Promise {
this.jobs.set(
job.sourceTransactionHash.toLowerCase(),
diff --git a/apps/worker/src/types.ts b/apps/worker/src/types.ts
index b1d6393..c68491d 100644
--- a/apps/worker/src/types.ts
+++ b/apps/worker/src/types.ts
@@ -33,6 +33,11 @@ export interface AttestcoinProof {
continuityRoots: string[];
}
+export interface CreditcoinExecution {
+ transactionHash: string;
+ queryId: string;
+}
+
export interface RelayJob {
sourceTransactionHash: string;
phase: RelayPhase;
@@ -40,16 +45,42 @@ export interface RelayJob {
updatedAt: string;
attempts: Partial>;
sourceBlockNumber?: number;
+ sourcePayment?: UsagePayment;
+ proof?: AttestcoinProof;
orderId?: string;
machineId?: string;
payer?: string;
accessExpiresAt?: string;
creditcoinTransactionHash?: string;
+ queryId?: string;
failedAtPhase?: RelayPhase;
error?: string;
failure?: RelayFailure;
}
+export interface PublicProofEvidence {
+ schema: 'proofkey.public-proof.v1';
+ source: {
+ transactionHash: string;
+ blockNumber?: number;
+ payment?: UsagePayment;
+ };
+ relay: {
+ phase: RelayPhase;
+ createdAt: string;
+ updatedAt: string;
+ attempts: Partial>;
+ failedAtPhase?: RelayPhase;
+ failure?: RelayFailure;
+ };
+ attestcoin?: AttestcoinProof;
+ creditcoin: {
+ transactionHash?: string;
+ queryId?: string;
+ accessExpiresAt?: string;
+ };
+}
+
export interface RelayFailure {
code: string;
message: string;
@@ -72,6 +103,7 @@ export interface JobStore {
export interface DurableJobStore extends JobStore {
create(job: RelayJob): Promise<{ job: RelayJob; created: boolean }>;
+ find(identifier: string): Promise;
claimNext(
ownerId: string,
leaseDurationMs: number,
@@ -111,7 +143,7 @@ export interface RelayAdapter {
waitUntilAttested(blockNumber: number): Promise;
generateProof(transactionHash: string): Promise;
isOrderProcessed(orderId: string): Promise;
- submitProof(proof: AttestcoinProof): Promise;
+ submitProof(proof: AttestcoinProof): Promise;
}
export interface StatusReporter {