Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Public network configuration. Copy this file to .env for local development.
ETHEREUM_SEPOLIA_CHAIN_ID=11155111
ETHEREUM_SEPOLIA_RPC_URL=
ETHEREUM_SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
ETHEREUM_SEPOLIA_EXPLORER_URL=https://sepolia.etherscan.io
PAYMENT_TOKEN_ADDRESS=
SEPOLIA_USAGE_PAYMENT_REGISTRY_ADDRESS=
PAYMENT_TOKEN_ADDRESS=0x43f2a86F5652957Aa5615413D406e037162a8247
SEPOLIA_USAGE_PAYMENT_REGISTRY_ADDRESS=0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA
SOURCE_CONFIRMATIONS=1

CREDITCOIN_TESTNET_CHAIN_ID=102031
CREDITCOIN_TESTNET_RPC_URL=https://rpc.cc3-testnet.creditcoin.network
CREDITCOIN_TESTNET_EXPLORER_URL=https://creditcoin-testnet.blockscout.com
CREDITCOIN_PROOF_BUILDER_URL=https://prover.cc3-testnet.creditcoin.network
SOURCE_CHAIN_KEY=1
PROOFKEY_ASC_ADDRESS=
PROOFKEY_ASC_ADDRESS=0x79fA79C1fdc7eFaA75Bc039CdbdFc1ce109775e7

CREDITCOIN_MAINNET_CHAIN_ID=102030
CREDITCOIN_MAINNET_RPC_URL=https://mainnet3.creditcoin.network
Expand Down Expand Up @@ -51,14 +51,18 @@ DEMO_SOURCE_TRANSACTION_HASH=
# never put a private key or secret in these variables.
VITE_CREDITCOIN_RPC_URL=https://rpc.cc3-testnet.creditcoin.network
VITE_CREDITCOIN_CHAIN_ID=102031
VITE_ACCESS_PASS_ADDRESS=
VITE_MACHINE_REGISTRY_ADDRESS=
VITE_DEMO_MACHINE_ID=
VITE_DEMO_BENEFICIARY_ADDRESS=
VITE_ACCESS_PASS_ADDRESS=0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA
VITE_MACHINE_REGISTRY_ADDRESS=0x43f2a86F5652957Aa5615413D406e037162a8247
VITE_DEMO_MACHINE_ID=0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc
VITE_DEMO_BENEFICIARY_ADDRESS=0x1114eeaFEB92B71bABf860E64e4575433a734B6A

# Customer web app public configuration.
VITE_ETHEREUM_SEPOLIA_RPC_URL=
VITE_USAGE_PAYMENT_REGISTRY_ADDRESS=
VITE_ETHEREUM_SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
VITE_USAGE_PAYMENT_REGISTRY_ADDRESS=0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA
VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK=5476972
VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK=11691302
VITE_PROOFKEY_ASC_ADDRESS=0x79fA79C1fdc7eFaA75Bc039CdbdFc1ce109775e7
VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK=5476974
# Public Reown Cloud project ID used by WalletConnect (never a private key).
VITE_WALLETCONNECT_PROJECT_ID=
VITE_PROOF_WORKER_URL=https://proofkey-relay.onrender.com
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

ProofKey lets a customer pay for machine time on Ethereum Sepolia and unlocks a non-transferable access credential on Creditcoin. Attestcoin proves the source transaction to Creditcoin without bridging assets or trusting the relay worker.

**Status:** live testnet Product V1 · 143 automated checks · verified Sepolia-to-Creditcoin flow
**Status:** live testnet Product V1 · 147 automated checks · five synchronized machines · verified Sepolia-to-Creditcoin flow

[Launch ProofKey](https://proofkey.vercel.app) · [View the Sepolia payment](https://sepolia.etherscan.io/tx/0xb646bed97cd5ecafec256ea121a3ab7b5d147cce9c38e9e8f5f96cccfd17b967) · [View the Creditcoin authorization](https://creditcoin-testnet.blockscout.com/tx/0x45313262557698e745662272a1da814b74bcebb65b39990b44603c78cca64510)

Expand Down Expand Up @@ -120,6 +120,8 @@ Live identifiers:

The secret-free deployment record is in [`packages/contracts/deployments/live-mvp.json`](packages/contracts/deployments/live-mvp.json). [`packages/contracts/fixtures/recorded-live-proof.json`](packages/contracts/fixtures/recorded-live-proof.json) contains the real proof material and is explicitly labeled `recorded-live` / `fresh: false`; it is historical evidence, not a fresh or replayable authorization.

The live catalog contains five synchronized testnet listings across construction, agriculture, energy, logistics, and manufacturing. [`machine-catalog-live.json`](packages/contracts/deployments/machine-catalog-live.json) records their public CC3 identities, Sepolia offers, metadata commitments, and transaction evidence. This proves listing synchronization; it does not claim independent inspection of the represented physical equipment.

All five deployed contracts are fully source-verified on Blockscout using the exact committed Hardhat compiler settings. Re-run `npm run verify:contracts` after compiling to verify the recorded deployments idempotently.

## Quick start
Expand All @@ -144,7 +146,7 @@ The complete local check does not require a funded wallet or private RPC endpoin

## Run the applications

Set the public `VITE_*` addresses from the committed deployment manifests and provide a Sepolia RPC URL in the ignored root `.env`. Set `VITE_WALLETCONNECT_PROJECT_ID` to a public Reown Cloud project ID to enable mobile QR connections. Live relaying also requires PostgreSQL through `DATABASE_URL`, plus `WORKER_PRIVATE_KEY`, `SEPOLIA_USAGE_PAYMENT_REGISTRY_ADDRESS`, and `PROOFKEY_ASC_ADDRESS`; the required fields are documented in `.env.example`. Never place private keys in `VITE_*` variables.
The browser uses the committed live testnet deployment and reviewed public RPC endpoints by default, so a Vercel build cannot become unusable because an optional public variable is absent. Public `VITE_*` values can override those defaults. Set `VITE_WALLETCONNECT_PROJECT_ID` to a public Reown Cloud project ID to enable mobile QR connections. Live relaying also requires PostgreSQL through `DATABASE_URL`, plus `WORKER_PRIVATE_KEY`, `SEPOLIA_USAGE_PAYMENT_REGISTRY_ADDRESS`, and `PROOFKEY_ASC_ADDRESS`; the required fields are documented in `.env.example`. Never place private keys in `VITE_*` variables.

Start each application in a separate terminal:

Expand Down Expand Up @@ -193,13 +195,15 @@ The gate runs formatting, TypeScript checks, all automated tests, Solidity compi
| ---------------- | ------: | ------------------------------------------------------------------------------------------------------ |
| Solidity | 52 | Receipt semantics, proof tampering, replay, authorization, pricing, ownership, expiry, reentrancy |
| Relay worker | 23 | Leases, restart recovery, one-time handoffs, receipt signatures, CORS, readiness, secret-safe evidence |
| Customer web | 51 | Proof state, exact token math, operator workflow, diagnostics, privacy-safe telemetry, and sessions |
| Product browser | 11 | Multi-page rental, wallets, proof, QR handoff, accessibility, payment, and recovery journeys |
| Customer web | 54 | Proof state, exact token math, catalog search, defaults, diagnostics, privacy-safe telemetry, sessions |
| Product browser | 12 | Multi-machine rental, wallets, proof, QR handoff, accessibility, payment, and recovery journeys |
| Device simulator | 6 | Locked/unlocking/unlocked/expired states, tampered results, RPC failure |
| **Total** | **143** | |
| **Total** | **147** | |

The Solidity suite uses explicit verifier doubles at `0x0FD2` to isolate adversarial proof cases. Those tests are distinct from the committed live CC3 transaction, which executed against Creditcoin's real Native Query Verifier.

The operator can add machines through `/operator`. For reproducible testnet catalog maintenance, the deployment owner can run `npm run seed:marketplace`; the command is idempotent, never writes secrets, and verifies owner, controller, metadata, tariff, active state, and payment offer on both chains before updating the public deployment record.

### Production readiness

`/diagnostics` gives operators a privacy-safe, read-only view of public configuration, relay readiness, and both required chain IDs. Client faults use stable codes and never record wallet addresses, transaction hashes, RPC URLs, or arbitrary error messages.
Expand Down
134 changes: 103 additions & 31 deletions apps/web/e2e/marketplace-checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import {
usageReceiptMessage,
type UsageReceiptPayload,
} from '../src/device-session.js';
import { allCatalogMachines } from '../src/machine-metadata.js';
import {
machineRegistryEvents,
paymentRegistryEvents,
} from '../src/marketplace.js';

const machineId =
'0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc';
Expand Down Expand Up @@ -41,12 +46,18 @@ const proofInterface = new Interface([
'event ProofKeyAccessActivated(bytes32 indexed queryId,bytes32 indexed orderId,bytes32 indexed machineId,address payer,uint64 expiresAt)',
]);
const orderId = `0x${'ee'.repeat(32)}`;
const catalogFixtures = allCatalogMachines().map((machine, index) => ({
...machine,
machineId: keccak256(toUtf8Bytes(machine.label)),
metadataHash: keccak256(toUtf8Bytes(machine.uri)),
blockOffset: index,
}));

test('Explore, machine detail, and checkout form one verified journey', async ({
page,
}) => {
await mockMarketplaceRpc(page);
await page.goto('/explore');
await page.goto('/explore', { waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('heading', { name: 'Industrial Excavator' }),
).toBeVisible({ timeout: 15_000 });
Expand All @@ -70,6 +81,34 @@ test('Explore, machine detail, and checkout form one verified journey', async ({
await expect(page.getByText('2 hours', { exact: true })).toBeVisible();
});

test('five live-style machine types are searchable and open distinct details', async ({
page,
}) => {
test.slow();
await mockMarketplaceRpc(page, false, true);
await page.goto('/explore');
await expect(page.locator('.catalog-card')).toHaveCount(5, {
timeout: 15_000,
});
await expect(page.locator('.result-count')).toContainText('05');
await expect(page.locator('.result-count')).toContainText('machines found');
await page
.getByPlaceholder('Search machine, capability or location')
.fill('solar power Abuja');
await expect(page.locator('.catalog-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Mobile Solar Power Unit' }),
).toBeVisible();
await page.getByRole('link', { name: 'View machine' }).click();
await expect(page).toHaveURL(
new RegExp(`/machines/${catalogFixtures[2]!.machineId}$`),
);
await expect(page.getByText('60 kWh battery storage')).toBeVisible();
await expect(
page.getByRole('link', { name: /Book machine time/ }),
).toHaveAttribute('href', `/rent/${catalogFixtures[2]!.machineId}`);
});

test('checkout fails closed when registry RPC is unavailable', async ({
page,
}) => {
Expand Down Expand Up @@ -446,7 +485,11 @@ test('customer and device browsers complete a one-time signed machine session',
]);
});

async function mockMarketplaceRpc(page: Page, includeUsage = false) {
async function mockMarketplaceRpc(
page: Page,
includeUsage = false,
includeCatalog = false,
) {
await page.route('https://**.rpc.proofkey.invalid/**', async (route) => {
const request = route.request();
const payload = request.postDataJSON() as RpcRequest | RpcRequest[];
Expand All @@ -455,7 +498,7 @@ async function mockMarketplaceRpc(page: Page, includeUsage = false) {
const responses = requests.map((rpc) => ({
jsonrpc: '2.0',
id: rpc.id,
result: rpcResult(rpc, isCreditcoin, includeUsage),
result: rpcResult(rpc, isCreditcoin, includeUsage, includeCatalog),
}));
await route.fulfill({
status: 200,
Expand All @@ -475,6 +518,7 @@ function rpcResult(
rpc: RpcRequest,
isCreditcoin: boolean,
includeUsage: boolean,
includeCatalog: boolean,
) {
let result: unknown;
if (rpc.method === 'eth_chainId')
Expand Down Expand Up @@ -504,8 +548,12 @@ function rpcResult(
? [usagePaid()]
: []
: isCreditcoin
? [cc3Registration()]
: [sepoliaOffer()];
? includeCatalog
? catalogFixtures.map((machine) => cc3Registration(machine))
: [cc3Registration()]
: includeCatalog
? catalogFixtures.map((machine) => sepoliaOffer(machine))
: [sepoliaOffer()];
} else if (rpc.method === 'eth_getBlockByNumber') {
result = creditcoinBlock();
} else if (rpc.method === 'eth_call') {
Expand All @@ -525,26 +573,46 @@ function rpcResult(
]);
else if (target.endsWith('04'))
result = accessInterface.encodeFunctionResult('isAuthorized', [true]);
else if (target.endsWith('02'))
else if (target.endsWith('02')) {
const requestedId = machineInterface.decodeFunctionData(
'machines',
call.data,
)[0] as string;
const fixture = includeCatalog
? catalogFixtures.find(
({ machineId: candidate }) =>
candidate.toLowerCase() === requestedId.toLowerCase(),
)
: undefined;
result = machineInterface.encodeFunctionResult('machines', [
owner,
owner,
metadataHash,
2500n,
fixture?.metadataHash ?? metadataHash,
fixture ? BigInt(fixture.tariff) : 2500n,
true,
]);
else if (
} else if (
target.endsWith('01') &&
call.data.startsWith(
paymentInterface.getFunction('machineOffers')!.selector,
)
)
) {
const requestedId = paymentInterface.decodeFunctionData(
'machineOffers',
call.data,
)[0] as string;
const fixture = includeCatalog
? catalogFixtures.find(
({ machineId: candidate }) =>
candidate.toLowerCase() === requestedId.toLowerCase(),
)
: undefined;
result = paymentInterface.encodeFunctionResult('machineOffers', [
owner,
2500n,
fixture ? BigInt(fixture.tariff) : 2500n,
true,
]);
else if (target.endsWith('01'))
} else if (target.endsWith('01'))
result = call.data.startsWith(
paymentInterface.getFunction('owner')!.selector,
)
Expand Down Expand Up @@ -994,39 +1062,43 @@ async function mockDeviceRelay(
});
}

function cc3Registration() {
function cc3Registration(fixture?: (typeof catalogFixtures)[number]) {
const id = fixture?.machineId ?? machineId;
const digest = fixture?.metadataHash ?? metadataHash;
const tariff = fixture ? BigInt(fixture.tariff) : 2500n;
const encoded = machineRegistryEvents.encodeEventLog(
machineRegistryEvents.getEvent('MachineRegistered')!,
[id, owner, owner, digest, tariff, true],
);
return {
address: '0x0000000000000000000000000000000000000002',
blockHash: `0x${'aa'.repeat(32)}`,
blockNumber: '0x539006',
transactionHash: `0x${'bb'.repeat(32)}`,
blockNumber: `0x${(0x539006 + (fixture?.blockOffset ?? 0)).toString(16)}`,
transactionHash: keccak256(toUtf8Bytes(`cc3:${id}`)),
transactionIndex: '0x0',
logIndex: '0x0',
removed: false,
topics: [
'0x986cbf5e3020e941aeaa92bffac52f24650187bfc582c05c3bee4bb284f31d77',
machineId,
`0x${'0'.repeat(24)}${owner.slice(2)}`,
`0x${'0'.repeat(24)}${owner.slice(2)}`,
],
data: `${metadataHash}${'0'.repeat(60)}09c4${'0'.repeat(63)}1`,
topics: encoded.topics,
data: encoded.data,
};
}

function sepoliaOffer() {
function sepoliaOffer(fixture?: (typeof catalogFixtures)[number]) {
const id = fixture?.machineId ?? machineId;
const tariff = fixture ? BigInt(fixture.tariff) : 2500n;
const encoded = paymentRegistryEvents.encodeEventLog(
paymentRegistryEvents.getEvent('MachineOfferSet')!,
[id, owner, tariff, true],
);
return {
address: '0x0000000000000000000000000000000000000001',
blockHash: `0x${'cc'.repeat(32)}`,
blockNumber: '0xb26bff',
transactionHash: `0x${'dd'.repeat(32)}`,
blockNumber: `0x${(0xb26bff + (fixture?.blockOffset ?? 0)).toString(16)}`,
transactionHash: keccak256(toUtf8Bytes(`sepolia:${id}`)),
transactionIndex: '0x0',
logIndex: '0x0',
removed: false,
topics: [
'0x7fe5f9ca822b5223f722e4b037ac183e3131d3747c2c4d537baf6b51448ce923',
machineId,
`0x${'0'.repeat(24)}${owner.slice(2)}`,
],
data: `0x${'0'.repeat(60)}09c4${'0'.repeat(63)}1`,
topics: encoded.topics,
data: encoded.data,
};
}
26 changes: 26 additions & 0 deletions apps/web/e2e/production-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ test('deployed product routes, assets, wallet modal, and relay are live', async
).toBeVisible();
await page.keyboard.press('Escape');

const explore = await page.goto('/explore', {
waitUntil: 'domcontentloaded',
});
expect(explore?.status()).toBe(200);
await expect(page.locator('.catalog-card')).toHaveCount(5);
await page
.getByPlaceholder('Search machine, capability or location')
.fill('solar power Abuja');
await expect(page.locator('.catalog-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Mobile Solar Power Unit' }),
).toBeVisible();

const machine = await page.goto(`/machines/${machineId}`, {
waitUntil: 'domcontentloaded',
});
Expand All @@ -51,9 +64,22 @@ test('deployed product routes, assets, wallet modal, and relay are live', async
page.locator('main h1, main [role="alert"] h2').first(),
).toBeVisible();

const diagnostics = await page.goto('/diagnostics', {
waitUntil: 'domcontentloaded',
});
expect(diagnostics?.status()).toBe(200);
await expect(
page.getByRole('heading', { name: 'All systems ready' }),
).toBeVisible();

const health = await request.get(`${relayUrl}/health`, { timeout: 60_000 });
expect(health.status()).toBe(200);
expect((await health.json()).status).toBe('alive');
const readiness = await request.get(`${relayUrl}/ready`, {
timeout: 60_000,
});
expect(readiness.status()).toBe(200);
expect((await readiness.json()).status).toBe('ready');
expect(failedAssets).toEqual([]);

const firstContentfulPaint = await page.evaluate(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 dist-test/proof.test.js dist-test/operator.test.js dist-test/device-session.test.js dist-test/diagnostics.test.js dist-test/telemetry.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 dist-test/operator.test.js dist-test/device-session.test.js dist-test/diagnostics.test.js dist-test/telemetry.test.js dist-test/live-config.test.js",
"test:e2e": "playwright test",
"test:smoke": "playwright test --config production-smoke.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
Expand Down
Loading
Loading