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 (
setSearch(event.target.value)} - placeholder="Paste a 0x Sepolia transaction hash" + placeholder="Paste a transaction hash, order ID, or query ID" + spellCheck={false} /> +
+ 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. + + +
+ +
+
+ ); +} + +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'} - -
- ))} -
-
- - Sepolia receipt - - {job.data.creditcoinTransactionHash && ( - - Creditcoin execution - + > + + {index < current || ['completed', 'duplicate'].includes(phase) ? ( + + ) : ( + index + 1 )} -
-
- ) : 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 {