From aa785ccce64c84f97b40c2e8463831e8f9d11a36 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Sun, 13 Sep 2026 21:56:34 +0100 Subject: [PATCH] feat: ship self-configuring multi-machine marketplace --- .env.example | 24 +- README.md | 14 +- apps/web/e2e/marketplace-checkout.spec.ts | 134 +++++-- apps/web/e2e/production-smoke.spec.ts | 26 ++ apps/web/package.json | 2 +- apps/web/src/components/ProductUI.tsx | 200 +++++++++-- apps/web/src/contracts.ts | 34 +- apps/web/src/live-config.test.ts | 42 +++ apps/web/src/live-config.ts | 14 + apps/web/src/machine-catalog.json | 139 ++++++++ apps/web/src/machine-metadata.ts | 65 ++-- apps/web/src/marketplace.test.ts | 63 ++++ apps/web/src/marketplace.ts | 9 +- apps/web/src/pages/MachinePage.tsx | 2 +- apps/web/src/pages/RentPage.tsx | 2 +- apps/web/src/wallet/config.ts | 9 +- apps/web/tsconfig.test.json | 2 + apps/worker/package.json | 1 + apps/worker/src/seed-marketplace.ts | 337 ++++++++++++++++++ package.json | 1 + packages/contracts/deployments/README.md | 2 + .../deployments/machine-catalog-live.json | 113 ++++++ scripts/audit-web-build.mjs | 27 +- scripts/validate-production-config.mjs | 24 +- 24 files changed, 1148 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/live-config.test.ts create mode 100644 apps/web/src/live-config.ts create mode 100644 apps/web/src/machine-catalog.json create mode 100644 apps/worker/src/seed-marketplace.ts create mode 100644 packages/contracts/deployments/machine-catalog-live.json diff --git a/.env.example b/.env.example index 1748165..50975d8 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # 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 @@ -11,7 +11,7 @@ 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 @@ -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 diff --git a/README.md b/README.md index 2ea0c30..adedcfb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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: @@ -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. diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts index 3e4cee8..64e59d2 100644 --- a/apps/web/e2e/marketplace-checkout.spec.ts +++ b/apps/web/e2e/marketplace-checkout.spec.ts @@ -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'; @@ -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 }); @@ -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, }) => { @@ -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[]; @@ -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, @@ -475,6 +518,7 @@ function rpcResult( rpc: RpcRequest, isCreditcoin: boolean, includeUsage: boolean, + includeCatalog: boolean, ) { let result: unknown; if (rpc.method === 'eth_chainId') @@ -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') { @@ -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, ) @@ -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, }; } diff --git a/apps/web/e2e/production-smoke.spec.ts b/apps/web/e2e/production-smoke.spec.ts index f21292c..4d26eaa 100644 --- a/apps/web/e2e/production-smoke.spec.ts +++ b/apps/web/e2e/production-smoke.spec.ts @@ -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', }); @@ -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( diff --git a/apps/web/package.json b/apps/web/package.json index fda3191..9892f65 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 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" diff --git a/apps/web/src/components/ProductUI.tsx b/apps/web/src/components/ProductUI.tsx index 01e0d2f..6d32249 100644 --- a/apps/web/src/components/ProductUI.tsx +++ b/apps/web/src/components/ProductUI.tsx @@ -35,10 +35,25 @@ export function PageHeading({ ); } -export function MachineArtwork({ compact = false }: { compact?: boolean }) { +export function MachineArtwork({ + compact = false, + variant = 'excavator', +}: { + compact?: boolean; + variant?: string; +}) { + const label = + { + excavator: 'PK / CONSTRUCTION', + tractor: 'PK / AGRICULTURE', + energy: 'PK / ENERGY', + coldchain: 'PK / LOGISTICS', + cnc: 'PK / MANUFACTURING', + }[variant] ?? 'PK / INDUSTRIAL'; return ( + + + ); + if (variant === 'cnc') + return ( + <> + + + + + + + + + + + ); + return ( + <> + + + + + + + + + + + + ); } @@ -128,7 +280,7 @@ export function MachineCard({ const resolvedId = machine?.machineId ?? machineId ?? ''; return (
- +
diff --git a/apps/web/src/contracts.ts b/apps/web/src/contracts.ts index 5ca993a..db81be0 100644 --- a/apps/web/src/contracts.ts +++ b/apps/web/src/contracts.ts @@ -10,6 +10,7 @@ import { randomBytes, type Eip1193Provider, } from 'ethers'; +import { liveTestnetConfig } from './live-config.js'; import { totalForDuration } from './flow.js'; import { normalizeProofWorkerUrl } from './worker.js'; @@ -100,15 +101,22 @@ export type PaymentUpdate = export function loadAppConfig(environment: ImportMetaEnv): AppConfig { const config: AppConfig = { - sepoliaRpcUrl: environment.VITE_ETHEREUM_SEPOLIA_RPC_URL?.trim() ?? '', - creditcoinRpcUrl: environment.VITE_CREDITCOIN_RPC_URL?.trim() ?? '', + sepoliaRpcUrl: + environment.VITE_ETHEREUM_SEPOLIA_RPC_URL?.trim() || + liveTestnetConfig.sepoliaRpcUrl, + creditcoinRpcUrl: + environment.VITE_CREDITCOIN_RPC_URL?.trim() || + liveTestnetConfig.creditcoinRpcUrl, registryAddress: - environment.VITE_USAGE_PAYMENT_REGISTRY_ADDRESS?.trim() ?? '', + environment.VITE_USAGE_PAYMENT_REGISTRY_ADDRESS?.trim() || + liveTestnetConfig.usagePaymentRegistryAddress, machineRegistryAddress: - environment.VITE_MACHINE_REGISTRY_ADDRESS?.trim() ?? '', - machineId: environment.VITE_DEMO_MACHINE_ID?.trim() ?? '', + environment.VITE_MACHINE_REGISTRY_ADDRESS?.trim() || + liveTestnetConfig.machineRegistryAddress, + machineId: + environment.VITE_DEMO_MACHINE_ID?.trim() || liveTestnetConfig.machineId, workerUrl: normalizeProofWorkerUrl( - environment.VITE_PROOF_WORKER_URL, + environment.VITE_PROOF_WORKER_URL?.trim() || liveTestnetConfig.workerUrl, environment.PROD, ), sepoliaExplorerUrl: ( @@ -123,21 +131,21 @@ export function loadAppConfig(environment: ImportMetaEnv): AppConfig { environment.VITE_DEMO_MACHINE_LOCATION ?? 'Lagos Demo Yard · Bay 04', creditcoinRegistryDeploymentBlock: parseDeploymentBlock( environment.VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK, - 5_476_972, + liveTestnetConfig.machineRegistryDeploymentBlock, ), sepoliaRegistryDeploymentBlock: parseDeploymentBlock( environment.VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK, - 11_691_302, + liveTestnetConfig.usagePaymentRegistryDeploymentBlock, ), accessPassAddress: - environment.VITE_ACCESS_PASS_ADDRESS?.trim() ?? - '0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA', + environment.VITE_ACCESS_PASS_ADDRESS?.trim() || + liveTestnetConfig.accessPassAddress, proofKeyAscAddress: - environment.VITE_PROOFKEY_ASC_ADDRESS?.trim() ?? - '0x79fA79C1fdc7eFaA75Bc039CdbdFc1ce109775e7', + environment.VITE_PROOFKEY_ASC_ADDRESS?.trim() || + liveTestnetConfig.proofKeyAscAddress, proofKeyAscDeploymentBlock: parseDeploymentBlock( environment.VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK, - 5_476_974, + liveTestnetConfig.proofKeyAscDeploymentBlock, ), }; if (!config.sepoliaRpcUrl) diff --git a/apps/web/src/live-config.test.ts b/apps/web/src/live-config.test.ts new file mode 100644 index 0000000..02d7ddb --- /dev/null +++ b/apps/web/src/live-config.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { loadAppConfig } from './contracts.js'; +import { liveTestnetConfig } from './live-config.js'; + +test('boots the committed live testnet deployment without Vercel variables', () => { + const config = loadAppConfig({ PROD: true } as unknown as ImportMetaEnv); + assert.equal(config.sepoliaRpcUrl, liveTestnetConfig.sepoliaRpcUrl); + assert.equal(config.creditcoinRpcUrl, liveTestnetConfig.creditcoinRpcUrl); + assert.equal( + config.registryAddress, + liveTestnetConfig.usagePaymentRegistryAddress, + ); + assert.equal( + config.machineRegistryAddress, + liveTestnetConfig.machineRegistryAddress, + ); + assert.equal(config.machineId, liveTestnetConfig.machineId); + assert.equal(config.workerUrl, liveTestnetConfig.workerUrl); +}); + +test('explicit public deployment values override every safe default', () => { + const config = loadAppConfig({ + PROD: true, + VITE_ETHEREUM_SEPOLIA_RPC_URL: 'https://sepolia.example', + VITE_CREDITCOIN_RPC_URL: 'https://creditcoin.example', + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: + '0x0000000000000000000000000000000000000011', + VITE_MACHINE_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000012', + VITE_ACCESS_PASS_ADDRESS: '0x0000000000000000000000000000000000000013', + VITE_PROOFKEY_ASC_ADDRESS: '0x0000000000000000000000000000000000000014', + VITE_DEMO_MACHINE_ID: `0x${'15'.repeat(32)}`, + VITE_PROOF_WORKER_URL: 'https://relay.example/', + } as unknown as ImportMetaEnv); + assert.equal(config.sepoliaRpcUrl, 'https://sepolia.example'); + assert.equal(config.creditcoinRpcUrl, 'https://creditcoin.example'); + assert.equal(config.workerUrl, 'https://relay.example'); + assert.equal( + config.registryAddress, + '0x0000000000000000000000000000000000000011', + ); +}); diff --git a/apps/web/src/live-config.ts b/apps/web/src/live-config.ts new file mode 100644 index 0000000..ed7fe14 --- /dev/null +++ b/apps/web/src/live-config.ts @@ -0,0 +1,14 @@ +export const liveTestnetConfig = { + sepoliaRpcUrl: 'https://ethereum-sepolia-rpc.publicnode.com', + creditcoinRpcUrl: 'https://rpc.cc3-testnet.creditcoin.network', + usagePaymentRegistryAddress: '0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA', + machineRegistryAddress: '0x43f2a86F5652957Aa5615413D406e037162a8247', + accessPassAddress: '0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA', + proofKeyAscAddress: '0x79fA79C1fdc7eFaA75Bc039CdbdFc1ce109775e7', + machineId: + '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc', + workerUrl: 'https://proofkey-relay.onrender.com', + machineRegistryDeploymentBlock: 5_476_972, + usagePaymentRegistryDeploymentBlock: 11_691_302, + proofKeyAscDeploymentBlock: 5_476_974, +} as const; diff --git a/apps/web/src/machine-catalog.json b/apps/web/src/machine-catalog.json new file mode 100644 index 0000000..e18e4e7 --- /dev/null +++ b/apps/web/src/machine-catalog.json @@ -0,0 +1,139 @@ +[ + { + "label": "proofkey.excavator.001", + "tariff": "2500", + "uri": "ipfs://proofkey/excavator/001", + "name": "Industrial Excavator", + "description": "A proof-gated hydraulic excavator for earthmoving and site preparation.", + "image": "excavator", + "category": "Construction", + "location": { + "city": "Lagos", + "country": "Nigeria", + "site": "Demo Yard · Bay 04" + }, + "capabilities": [ + "22-ton operating capacity", + "GPS telemetry", + "Remote access controller" + ], + "safetyRequirements": [ + "Verified operator briefing", + "Hard hat and high-visibility vest" + ], + "operator": { + "name": "ProofKey Industrial", + "wallet": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "verified": true + } + }, + { + "label": "proofkey.tractor.001", + "tariff": "1800", + "uri": "ipfs://proofkey/tractor/001", + "name": "Autonomous Field Tractor", + "description": "A GPS-guided agricultural tractor for precision tilling, seeding, and field preparation.", + "image": "tractor", + "category": "Agriculture", + "location": { + "city": "Ibadan", + "country": "Nigeria", + "site": "AgriTech Field · Plot 12" + }, + "capabilities": [ + "RTK-guided navigation", + "120 horsepower", + "Precision implement control" + ], + "safetyRequirements": ["Geofenced operating zone", "Remote stop briefing"], + "operator": { + "name": "ProofKey Industrial", + "wallet": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "verified": true + } + }, + { + "label": "proofkey.energy.001", + "tariff": "3200", + "uri": "ipfs://proofkey/energy/001", + "name": "Mobile Solar Power Unit", + "description": "A towable solar and battery system delivering metered off-grid power to worksites and events.", + "image": "energy", + "category": "Energy", + "location": { + "city": "Abuja", + "country": "Nigeria", + "site": "Green Energy Hub · Dock 03" + }, + "capabilities": [ + "60 kWh battery storage", + "12 kW solar input", + "Remote load telemetry" + ], + "safetyRequirements": [ + "Qualified electrical operator", + "Dry and ventilated placement" + ], + "operator": { + "name": "ProofKey Industrial", + "wallet": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "verified": true + } + }, + { + "label": "proofkey.coldchain.001", + "tariff": "1400", + "uri": "ipfs://proofkey/coldchain/001", + "name": "Refrigerated Cargo Pod", + "description": "A sensor-rich cold-chain container for temperature-controlled food and medicine storage.", + "image": "coldchain", + "category": "Logistics", + "location": { + "city": "Accra", + "country": "Ghana", + "site": "Trade Depot · Cold Bay 07" + }, + "capabilities": [ + "-20°C to 10°C setpoint", + "Continuous temperature log", + "Door and power telemetry" + ], + "safetyRequirements": [ + "Approved cargo manifest", + "Vent clearance inspection" + ], + "operator": { + "name": "ProofKey Industrial", + "wallet": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "verified": true + } + }, + { + "label": "proofkey.cnc.001", + "tariff": "4100", + "uri": "ipfs://proofkey/cnc/001", + "name": "Precision CNC Milling Cell", + "description": "A networked five-axis milling cell for short-run metal parts and verified machine time.", + "image": "cnc", + "category": "Manufacturing", + "location": { + "city": "Nairobi", + "country": "Kenya", + "site": "Maker District · Cell 05" + }, + "capabilities": [ + "Five-axis machining", + "Automatic tool changer", + "Usage and spindle telemetry" + ], + "safetyRequirements": [ + "CAM program review", + "Machine enclosure inspection" + ], + "operator": { + "name": "ProofKey Industrial", + "wallet": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "verified": true + } + } +] diff --git a/apps/web/src/machine-metadata.ts b/apps/web/src/machine-metadata.ts index 94d0041..a6f6543 100644 --- a/apps/web/src/machine-metadata.ts +++ b/apps/web/src/machine-metadata.ts @@ -1,3 +1,5 @@ +import catalog from './machine-catalog.json' with { type: 'json' }; + export interface MachineMetadata { uri: string; name: string; @@ -10,43 +12,30 @@ export interface MachineMetadata { operator: { name: string; wallet: string; verified: boolean }; } -const metadataByUri = new Map([ - [ - 'ipfs://proofkey/excavator/001', - { - uri: 'ipfs://proofkey/excavator/001', - name: 'Industrial Excavator', - description: - 'A proof-gated hydraulic excavator for earthmoving and site preparation.', - image: 'excavator', - category: 'Construction', - location: { - city: 'Lagos', - country: 'Nigeria', - site: 'Demo Yard · Bay 04', - }, - capabilities: [ - '22-ton operating capacity', - 'GPS telemetry', - 'Remote access controller', - ], - safetyRequirements: [ - 'Verified operator briefing', - 'Hard hat and high-visibility vest', - ], - operator: { - name: 'ProofKey Industrial', - wallet: '0x1114eeafeb92b71babf860e64e4575433a734b6a', - verified: true, - }, - }, - ], -]); +export interface CatalogMachine extends MachineMetadata { + label: string; + tariff: string; +} + +const machineCatalog = catalog.filter(isCatalogMachine) as CatalogMachine[]; +if (machineCatalog.length !== catalog.length) + throw new Error('The committed machine catalog contains invalid metadata.'); + +const metadataByUri = new Map( + machineCatalog.map(({ label: _label, tariff: _tariff, ...metadata }) => [ + metadata.uri, + metadata, + ]), +); export function allMachineMetadata() { return [...metadataByUri.values()]; } +export function allCatalogMachines() { + return [...machineCatalog]; +} + export function isMachineMetadata(value: unknown): value is MachineMetadata { const item = value as Partial | undefined; return Boolean( @@ -70,3 +59,15 @@ export function isMachineMetadata(value: unknown): value is MachineMetadata { typeof item.operator.verified === 'boolean', ); } + +function isCatalogMachine(value: unknown): value is CatalogMachine { + const item = value as Partial | undefined; + return Boolean( + isMachineMetadata(value) && + typeof item?.label === 'string' && + item.label.length > 0 && + typeof item.tariff === 'string' && + /^\d+$/.test(item.tariff) && + BigInt(item.tariff) > 0n, + ); +} diff --git a/apps/web/src/marketplace.test.ts b/apps/web/src/marketplace.test.ts index 93a3ac4..5beef77 100644 --- a/apps/web/src/marketplace.test.ts +++ b/apps/web/src/marketplace.test.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { keccak256, toUtf8Bytes } from 'ethers'; +import { allCatalogMachines } from './machine-metadata.js'; import { filterMarketplace, machineRegistryEvents, @@ -126,3 +128,64 @@ test('filters, sorts, and paginates marketplace results', () => { assert.equal(result.items[0]?.machineId, machineId); assert.equal(result.totalPages, 1); }); + +test('diverse committed machines reconcile into a searchable catalog', () => { + const catalog = allCatalogMachines(); + assert.equal(catalog.length, 5); + assert.equal(new Set(catalog.map(({ category }) => category)).size, 5); + const machines = new Map( + catalog.map((metadata, index) => { + const id = keccak256(toUtf8Bytes(metadata.label)); + return [ + id, + { + machineId: id, + owner: '0x1114eeaFEB92B71bABf860E64e4575433a734B6A', + controller: '0x1114eeaFEB92B71bABf860E64e4575433a734B6A', + metadataHash: keccak256(toUtf8Bytes(metadata.uri)), + tariff: BigInt(metadata.tariff), + active: true, + registeredAtBlock: 100 + index, + updatedAtBlock: 100 + index, + }, + ] as const; + }), + ); + const offers = new Map( + [...machines.values()].map((machine) => [ + machine.machineId, + { + machineId: machine.machineId, + beneficiary: machine.owner, + pricePerSecond: machine.tariff, + active: true, + updatedAtBlock: 200, + }, + ]), + ); + const listings = reconcileMarketplace( + machines, + offers, + { + address: '0x0000000000000000000000000000000000000001', + decimals: 6, + symbol: 'USDC', + }, + catalog, + ); + assert.equal( + listings.every(({ status }) => status === 'available'), + true, + ); + const result = filterMarketplace(listings, { + query: 'solar power Abuja', + category: 'Energy', + location: 'Abuja', + availability: 'available', + sort: 'availability', + page: 1, + pageSize: 6, + }); + assert.equal(result.total, 1); + assert.equal(result.items[0]?.metadata?.name, 'Mobile Solar Power Unit'); +}); diff --git a/apps/web/src/marketplace.ts b/apps/web/src/marketplace.ts index 359054c..5748923 100644 --- a/apps/web/src/marketplace.ts +++ b/apps/web/src/marketplace.ts @@ -214,7 +214,11 @@ export function filterMarketplace( machines: readonly MarketplaceMachine[], filters: MarketplaceFilters, ) { - const needle = filters.query.trim().toLowerCase(); + const needles = filters.query + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); const matching = machines.filter((machine) => { const metadata = machine.metadata; const searchable = [ @@ -230,7 +234,8 @@ export function filterMarketplace( .join(' ') .toLowerCase(); return ( - (!needle || searchable.includes(needle)) && + (needles.length === 0 || + needles.every((needle) => searchable.includes(needle))) && (!filters.category || metadata?.category === filters.category) && (!filters.location || metadata?.location.city === filters.location) && (!filters.availability || machine.status === filters.availability) diff --git a/apps/web/src/pages/MachinePage.tsx b/apps/web/src/pages/MachinePage.tsx index 0935b0b..bc2c325 100644 --- a/apps/web/src/pages/MachinePage.tsx +++ b/apps/web/src/pages/MachinePage.tsx @@ -107,7 +107,7 @@ export function Component() { Explore machines
- +
diff --git a/apps/web/src/pages/RentPage.tsx b/apps/web/src/pages/RentPage.tsx index 78288bb..f00b413 100644 --- a/apps/web/src/pages/RentPage.tsx +++ b/apps/web/src/pages/RentPage.tsx @@ -396,7 +396,7 @@ export function Component() {
- +

VERIFIED RENTAL

{machine.metadata?.name}

diff --git a/apps/web/src/wallet/config.ts b/apps/web/src/wallet/config.ts index 71390c6..52611f4 100644 --- a/apps/web/src/wallet/config.ts +++ b/apps/web/src/wallet/config.ts @@ -4,6 +4,7 @@ import { createConfig, http } from 'wagmi'; import { sepolia } from 'wagmi/chains'; import { coinbaseWallet, injected } from 'wagmi/connectors'; import { defineChain } from 'viem'; +import { liveTestnetConfig } from '../live-config.js'; export const creditcoinTestnet = defineChain({ id: 102031, @@ -13,7 +14,7 @@ export const creditcoinTestnet = defineChain({ default: { http: [ import.meta.env.VITE_CREDITCOIN_RPC_URL?.trim() || - 'https://rpc.cc3-testnet.creditcoin.network', + liveTestnetConfig.creditcoinRpcUrl, ], }, }, @@ -40,10 +41,12 @@ const connectors = [ ] as const; const transports = { [sepolia.id]: http( - import.meta.env.VITE_ETHEREUM_SEPOLIA_RPC_URL?.trim() || undefined, + import.meta.env.VITE_ETHEREUM_SEPOLIA_RPC_URL?.trim() || + liveTestnetConfig.sepoliaRpcUrl, ), [creditcoinTestnet.id]: http( - import.meta.env.VITE_CREDITCOIN_RPC_URL?.trim() || undefined, + import.meta.env.VITE_CREDITCOIN_RPC_URL?.trim() || + liveTestnetConfig.creditcoinRpcUrl, ), }; diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json index c0fd39a..08f568c 100644 --- a/apps/web/tsconfig.test.json +++ b/apps/web/tsconfig.test.json @@ -21,6 +21,8 @@ "src/rental.ts", "src/rental.test.ts", "src/contracts.ts", + "src/live-config.ts", + "src/live-config.test.ts", "src/vite-env.d.ts", "src/activity.ts", "src/activity.test.ts", diff --git a/apps/worker/package.json b/apps/worker/package.json index 76b7509..37125d7 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -8,6 +8,7 @@ "live:mvp": "npm run build && node dist/live-mvp.js", "live:verify": "npm run build && node dist/verify-live.js", "relay": "npm run build && node dist/cli.js", + "seed:marketplace": "npm run build && node dist/seed-marketplace.js", "serve": "npm run build && node dist/server.js", "test": "npm run build && node --test dist/evidence.test.js dist/executor.test.js dist/http.test.js dist/queue.test.js dist/relay.test.js dist/store.test.js dist/usage-receipt.test.js", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/apps/worker/src/seed-marketplace.ts b/apps/worker/src/seed-marketplace.ts new file mode 100644 index 0000000..daa4cb4 --- /dev/null +++ b/apps/worker/src/seed-marketplace.ts @@ -0,0 +1,337 @@ +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { + Contract, + JsonRpcProvider, + Wallet, + getAddress, + keccak256, + toUtf8Bytes, + type ContractTransactionResponse, +} from 'ethers'; + +const SEPOLIA_CHAIN_ID = 11_155_111n; +const CREDITCOIN_CHAIN_ID = 102_031n; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +interface CatalogMachine { + label: string; + tariff: string; + uri: string; + name: string; + operator: { wallet: string }; +} + +interface DeploymentRecord { + address: string; + blockNumber: number; +} + +interface SepoliaDeployment { + chainId: number; + usagePaymentRegistry: DeploymentRecord; +} + +interface CreditcoinDeployment { + chainId: number; + machineRegistry: DeploymentRecord; +} + +const machineRegistryAbi = [ + 'function machines(bytes32) view returns (address owner,address controller,bytes32 metadataHash,uint128 tariff,bool active)', + 'function registerMachine(bytes32,address,bytes32,uint128,bool)', + 'function updateController(bytes32,address)', + 'function updateMetadataHash(bytes32,bytes32)', + 'function updateTariff(bytes32,uint128)', + 'function setMachineActive(bytes32,bool)', +] as const; +const paymentRegistryAbi = [ + 'function owner() view returns (address)', + 'function machineOffers(bytes32) view returns (address beneficiary,uint128 pricePerSecond,bool active)', + 'function setMachineOffer(bytes32,address,uint128,bool)', +] as const; + +async function main() { + const root = resolve(import.meta.dirname, '../../..'); + const output = resolve( + root, + 'packages/contracts/deployments/machine-catalog-live.json', + ); + const environmentPath = resolve(root, '.env'); + if (existsSync(environmentPath)) process.loadEnvFile(environmentPath); + + const [catalog, sepoliaDeployment, creditcoinDeployment] = await Promise.all([ + readJson( + resolve(root, 'apps/web/src/machine-catalog.json'), + ), + readJson( + resolve(root, 'packages/contracts/deployments/sepolia.json'), + ), + readJson( + resolve(root, 'packages/contracts/deployments/cc3-testnet.json'), + ), + ]); + assertCatalog(catalog); + const previous = existsSync(output) + ? await readJson<{ + machines?: Array<{ + machineId: string; + updates?: Array>; + }>; + }>(output) + : undefined; + if (BigInt(sepoliaDeployment.chainId) !== SEPOLIA_CHAIN_ID) + throw new Error('Sepolia deployment manifest has the wrong chain ID.'); + if (BigInt(creditcoinDeployment.chainId) !== CREDITCOIN_CHAIN_ID) + throw new Error('Creditcoin deployment manifest has the wrong chain ID.'); + + const privateKey = requiredPrivateKey('DEPLOYER_PRIVATE_KEY'); + const sepoliaProvider = new JsonRpcProvider( + required('ETHEREUM_SEPOLIA_RPC_URL'), + Number(SEPOLIA_CHAIN_ID), + { staticNetwork: true }, + ); + const creditcoinProvider = new JsonRpcProvider( + required('CREDITCOIN_TESTNET_RPC_URL'), + Number(CREDITCOIN_CHAIN_ID), + { staticNetwork: true }, + ); + const [sepoliaNetwork, creditcoinNetwork] = await Promise.all([ + sepoliaProvider.getNetwork(), + creditcoinProvider.getNetwork(), + ]); + if (sepoliaNetwork.chainId !== SEPOLIA_CHAIN_ID) + throw new Error(`Sepolia RPC returned chain ${sepoliaNetwork.chainId}.`); + if (creditcoinNetwork.chainId !== CREDITCOIN_CHAIN_ID) + throw new Error( + `Creditcoin RPC returned chain ${creditcoinNetwork.chainId}.`, + ); + + const sepoliaWallet = new Wallet(privateKey, sepoliaProvider); + const creditcoinWallet = new Wallet(privateKey, creditcoinProvider); + const owner = getAddress(sepoliaWallet.address); + if (owner !== getAddress(creditcoinWallet.address)) + throw new Error('The configured deployer must control both networks.'); + const paymentRegistry = new Contract( + sepoliaDeployment.usagePaymentRegistry.address, + paymentRegistryAbi, + sepoliaWallet, + ); + const machineRegistry = new Contract( + creditcoinDeployment.machineRegistry.address, + machineRegistryAbi, + creditcoinWallet, + ); + if ( + getAddress((await paymentRegistry.getFunction('owner')()) as string) !== + owner + ) + throw new Error('DEPLOYER_PRIVATE_KEY is not the Sepolia registry owner.'); + + const records = []; + for (const machine of catalog) { + if (getAddress(machine.operator.wallet) !== owner) + throw new Error(`${machine.label} operator does not match the deployer.`); + const machineId = keccak256(toUtf8Bytes(machine.label)); + const metadataHash = keccak256(toUtf8Bytes(machine.uri)); + const tariff = BigInt(machine.tariff); + const updates: Array> = []; + const current = await machineRegistry.getFunction('machines')(machineId); + if (getAddress(current.owner as string) === ZERO_ADDRESS) { + updates.push( + await submit( + 'creditcoin:register', + machine.label, + machineRegistry.getFunction('registerMachine')( + machineId, + owner, + metadataHash, + tariff, + true, + ), + ), + ); + } else { + if (getAddress(current.owner as string) !== owner) + throw new Error(`${machine.label} is owned by another account.`); + if (getAddress(current.controller as string) !== owner) + updates.push( + await submit( + 'creditcoin:controller', + machine.label, + machineRegistry.getFunction('updateController')(machineId, owner), + ), + ); + if ((current.metadataHash as string).toLowerCase() !== metadataHash) + updates.push( + await submit( + 'creditcoin:metadata', + machine.label, + machineRegistry.getFunction('updateMetadataHash')( + machineId, + metadataHash, + ), + ), + ); + if ((current.tariff as bigint) !== tariff) + updates.push( + await submit( + 'creditcoin:tariff', + machine.label, + machineRegistry.getFunction('updateTariff')(machineId, tariff), + ), + ); + if (!(current.active as boolean)) + updates.push( + await submit( + 'creditcoin:activate', + machine.label, + machineRegistry.getFunction('setMachineActive')(machineId, true), + ), + ); + } + + const offer = await paymentRegistry.getFunction('machineOffers')(machineId); + if ( + getAddress(offer.beneficiary as string) !== owner || + (offer.pricePerSecond as bigint) !== tariff || + !(offer.active as boolean) + ) + updates.push( + await submit( + 'sepolia:offer', + machine.label, + paymentRegistry.getFunction('setMachineOffer')( + machineId, + owner, + tariff, + true, + ), + ), + ); + + const [verifiedMachine, verifiedOffer] = await Promise.all([ + machineRegistry.getFunction('machines')(machineId), + paymentRegistry.getFunction('machineOffers')(machineId), + ]); + if ( + getAddress(verifiedMachine.owner as string) !== owner || + getAddress(verifiedMachine.controller as string) !== owner || + (verifiedMachine.metadataHash as string).toLowerCase() !== metadataHash || + (verifiedMachine.tariff as bigint) !== tariff || + !(verifiedMachine.active as boolean) || + getAddress(verifiedOffer.beneficiary as string) !== owner || + (verifiedOffer.pricePerSecond as bigint) !== tariff || + !(verifiedOffer.active as boolean) + ) + throw new Error(`${machine.label} failed post-transaction verification.`); + records.push({ + label: machine.label, + name: machine.name, + machineId, + metadataUri: machine.uri, + metadataHash, + tariff: machine.tariff, + owner, + active: true, + updates: [ + ...(previous?.machines?.find( + ({ machineId: previousId }) => + previousId.toLowerCase() === machineId.toLowerCase(), + )?.updates ?? []), + ...updates, + ], + }); + console.log( + `${machine.name}: synchronized${updates.length ? ` (${updates.length} update${updates.length === 1 ? '' : 's'})` : ' (no changes)'}`, + ); + } + + await writeFile( + output, + `${JSON.stringify( + { + provenance: { + kind: 'live-testnet-catalog', + note: 'Public machine identity and synchronization evidence only; no secret material.', + }, + verifiedAt: new Date().toISOString(), + owner, + networks: { + creditcoin: Number(CREDITCOIN_CHAIN_ID), + sepolia: Number(SEPOLIA_CHAIN_ID), + }, + machines: records, + }, + null, + 2, + )}\n`, + 'utf8', + ); + console.log(`Verified ${records.length} synchronized machine listings.`); +} + +async function submit( + action: string, + label: string, + pending: Promise, +) { + const transaction = await pending; + console.log(`${action} ${label}: ${transaction.hash}`); + const receipt = await transaction.wait(); + if (!receipt || receipt.status !== 1) + throw new Error(`${action} failed for ${label}.`); + return { + action, + transactionHash: transaction.hash, + blockNumber: receipt.blockNumber, + }; +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T; +} + +function assertCatalog(value: CatalogMachine[]) { + if (!Array.isArray(value) || value.length < 5) + throw new Error('Machine catalog must contain at least five entries.'); + const labels = new Set(); + for (const machine of value) { + if ( + !machine || + typeof machine.label !== 'string' || + typeof machine.name !== 'string' || + typeof machine.uri !== 'string' || + !machine.uri.startsWith('ipfs://proofkey/') || + !/^\d+$/.test(machine.tariff) || + BigInt(machine.tariff) <= 0n || + labels.has(machine.label) + ) + throw new Error( + 'Machine catalog contains an invalid or duplicate entry.', + ); + labels.add(machine.label); + } +} + +function required(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required environment variable ${name}.`); + return value; +} + +function requiredPrivateKey(name: string) { + const configured = required(name); + const value = configured.startsWith('0x') ? configured : `0x${configured}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) + throw new Error(`${name} must be a 32-byte hexadecimal value.`); + return value; +} + +main().catch((error: unknown) => { + console.error( + error instanceof Error ? error.message : 'Catalog seed failed.', + ); + process.exitCode = 1; +}); diff --git a/package.json b/package.json index 123cd2a..3c19b2a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "format": "prettier --write .", "live:mvp": "npm run live:mvp --workspace @proofkey/worker", "live:verify": "npm run live:verify --workspace @proofkey/worker", + "seed:marketplace": "npm run seed:marketplace --workspace @proofkey/worker", "lint": "prettier --check .", "test": "npm run test --workspaces --if-present", "test:contracts": "npm run test --workspace @proofkey/contracts", diff --git a/packages/contracts/deployments/README.md b/packages/contracts/deployments/README.md index 6a37319..d08db71 100644 --- a/packages/contracts/deployments/README.md +++ b/packages/contracts/deployments/README.md @@ -7,3 +7,5 @@ Review the output before committing a deployment record. Never commit deployment keys or RPC credentials. After both deployments exist, `npm run live:mvp --workspace @proofkey/worker` performs the real cross-chain demo and writes `live-mvp.json`. That evidence file and `../fixtures/recorded-live-proof.json` are emitted only after Creditcoin reports the payer as authorized. Both are public, secret-scanned records and are explicitly labeled as historical recorded-live evidence. + +`npm run seed:marketplace` idempotently synchronizes the committed five-machine catalog across the CC3 `MachineRegistry` and Sepolia `UsagePaymentRegistry`, verifies the resulting state, and writes `machine-catalog-live.json`. The file contains public transaction evidence only and does not certify the physical existence or condition of a represented machine. diff --git a/packages/contracts/deployments/machine-catalog-live.json b/packages/contracts/deployments/machine-catalog-live.json new file mode 100644 index 0000000..7b690b1 --- /dev/null +++ b/packages/contracts/deployments/machine-catalog-live.json @@ -0,0 +1,113 @@ +{ + "provenance": { + "kind": "live-testnet-catalog", + "note": "Public machine identity and synchronization evidence only; no secret material." + }, + "verifiedAt": "2026-09-13T20:13:22.429Z", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "networks": { + "creditcoin": 102031, + "sepolia": 11155111 + }, + "machines": [ + { + "label": "proofkey.excavator.001", + "name": "Industrial Excavator", + "machineId": "0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc", + "metadataUri": "ipfs://proofkey/excavator/001", + "metadataHash": "0x24f58d3fcaa80aa0cbe4c88b0ce7d4a23312fa95ca201300a7313894970e883e", + "tariff": "2500", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "active": true, + "updates": [] + }, + { + "label": "proofkey.tractor.001", + "name": "Autonomous Field Tractor", + "machineId": "0x4d673d80ca7d15bb870d8df7397e3faac88f60b2a918beb5c215e5cc1a8aff0c", + "metadataUri": "ipfs://proofkey/tractor/001", + "metadataHash": "0xff85fc745528ac9c10083098d766792ced398c012a4892b86ccbf00e79eccd3a", + "tariff": "1800", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "active": true, + "updates": [ + { + "action": "creditcoin:register", + "transactionHash": "0x140a6598169755a984a85c0885f39dc26a3878b59bea78f6a2400cc59eb0799f", + "blockNumber": 5482431 + }, + { + "action": "sepolia:offer", + "transactionHash": "0x29e31c180c2fde8a959f7ff926d49ca5b143528c3074e7ee456b57c3219e8265", + "blockNumber": 11697952 + } + ] + }, + { + "label": "proofkey.energy.001", + "name": "Mobile Solar Power Unit", + "machineId": "0x8dbcb34d2311b1fd5d86e56650dc2c71daa7f194d3bb1e8f798e108cbef2611f", + "metadataUri": "ipfs://proofkey/energy/001", + "metadataHash": "0xea6041cc1b15985e03bb886760429c739a0a62867e33d72f7b05fe3a0fa6f9a6", + "tariff": "3200", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "active": true, + "updates": [ + { + "action": "creditcoin:register", + "transactionHash": "0x1fa7f6acfb9e0d815870076163f42ed70bcd7008eadee3fa7157b385c713e002", + "blockNumber": 5482433 + }, + { + "action": "sepolia:offer", + "transactionHash": "0x7404ffb1653587505f7c0b5fd173a58ef84fb26e8b4ebe753303bee430e45963", + "blockNumber": 11697955 + } + ] + }, + { + "label": "proofkey.coldchain.001", + "name": "Refrigerated Cargo Pod", + "machineId": "0xdc40bf68b56301609cebb894644c36d415df694098cde5e435f00398df4af53b", + "metadataUri": "ipfs://proofkey/coldchain/001", + "metadataHash": "0xfec650583b0f2a52a4b2fb82b684f48aa34231b55ce2db4c607c81cbd3c69af7", + "tariff": "1400", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "active": true, + "updates": [ + { + "action": "creditcoin:register", + "transactionHash": "0x16c0f1bef2eb9ade0977358b3f25b790f497a06485d7b290e7191a1cbbeb9fba", + "blockNumber": 5482435 + }, + { + "action": "sepolia:offer", + "transactionHash": "0xeee447b5c47d08ea0efd049e39b76741c823c59eac905c0129e88c74e0033ab1", + "blockNumber": 11697957 + } + ] + }, + { + "label": "proofkey.cnc.001", + "name": "Precision CNC Milling Cell", + "machineId": "0xb3c404059df1545ade309532a431b2d826296994c1670f9a14a0c5959d271a25", + "metadataUri": "ipfs://proofkey/cnc/001", + "metadataHash": "0xf11e2437777ff8a23462d1b4a170d8c0bf57e98163952c3d3607f3e8bd1e304c", + "tariff": "4100", + "owner": "0x1114eeaFEB92B71bABf860E64e4575433a734B6A", + "active": true, + "updates": [ + { + "action": "creditcoin:register", + "transactionHash": "0x52627fde773a3b472e6562a8f14f1665f6a3f4b429b92eb5da8810193ddf09ba", + "blockNumber": 5482437 + }, + { + "action": "sepolia:offer", + "transactionHash": "0xd22b9e29b54ac4c7f87e21091e84f9d931f4e5ff2109f3d23845d3d13b35ec01", + "blockNumber": 11697960 + } + ] + } + ] +} diff --git a/scripts/audit-web-build.mjs b/scripts/audit-web-build.mjs index fac438a..7723491 100644 --- a/scripts/audit-web-build.mjs +++ b/scripts/audit-web-build.mjs @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { gzipSync } from 'node:zlib'; import { extname, join, relative } from 'node:path'; +import { liveTestnetConfig } from '../apps/web/src/live-config.ts'; const root = join(import.meta.dirname, '..'); const output = join(root, 'apps', 'web', 'dist'); @@ -45,18 +46,20 @@ for (const check of forbidden) if (check.pattern.test(combined)) failures.push(`Client bundle contains a forbidden ${check.label}.`); -for (const name of [ - 'VITE_ETHEREUM_SEPOLIA_RPC_URL', - 'VITE_CREDITCOIN_RPC_URL', - 'VITE_PROOF_WORKER_URL', - 'VITE_USAGE_PAYMENT_REGISTRY_ADDRESS', - 'VITE_MACHINE_REGISTRY_ADDRESS', - 'VITE_ACCESS_PASS_ADDRESS', - 'VITE_PROOFKEY_ASC_ADDRESS', - 'VITE_DEMO_MACHINE_ID', -]) { - const value = process.env[name]; - if (!value || !combined.toLowerCase().includes(value.toLowerCase())) +const publicDefaults = { + VITE_ETHEREUM_SEPOLIA_RPC_URL: liveTestnetConfig.sepoliaRpcUrl, + VITE_CREDITCOIN_RPC_URL: liveTestnetConfig.creditcoinRpcUrl, + VITE_PROOF_WORKER_URL: liveTestnetConfig.workerUrl, + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: + liveTestnetConfig.usagePaymentRegistryAddress, + VITE_MACHINE_REGISTRY_ADDRESS: liveTestnetConfig.machineRegistryAddress, + VITE_ACCESS_PASS_ADDRESS: liveTestnetConfig.accessPassAddress, + VITE_PROOFKEY_ASC_ADDRESS: liveTestnetConfig.proofKeyAscAddress, + VITE_DEMO_MACHINE_ID: liveTestnetConfig.machineId, +}; +for (const [name, fallback] of Object.entries(publicDefaults)) { + const value = process.env[name]?.trim() || fallback; + if (!combined.toLowerCase().includes(value.toLowerCase())) failures.push(`${name} was not emitted into the production bundle.`); } diff --git a/scripts/validate-production-config.mjs b/scripts/validate-production-config.mjs index 33bb9eb..d0c7e96 100644 --- a/scripts/validate-production-config.mjs +++ b/scripts/validate-production-config.mjs @@ -1,3 +1,5 @@ +import { liveTestnetConfig } from '../apps/web/src/live-config.ts'; + const requiredUrls = [ 'VITE_ETHEREUM_SEPOLIA_RPC_URL', 'VITE_CREDITCOIN_RPC_URL', @@ -10,9 +12,25 @@ const requiredAddresses = [ 'VITE_PROOFKEY_ASC_ADDRESS', ]; +const defaults = { + VITE_ETHEREUM_SEPOLIA_RPC_URL: liveTestnetConfig.sepoliaRpcUrl, + VITE_CREDITCOIN_RPC_URL: liveTestnetConfig.creditcoinRpcUrl, + VITE_PROOF_WORKER_URL: liveTestnetConfig.workerUrl, + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: + liveTestnetConfig.usagePaymentRegistryAddress, + VITE_MACHINE_REGISTRY_ADDRESS: liveTestnetConfig.machineRegistryAddress, + VITE_ACCESS_PASS_ADDRESS: liveTestnetConfig.accessPassAddress, + VITE_PROOFKEY_ASC_ADDRESS: liveTestnetConfig.proofKeyAscAddress, + VITE_DEMO_MACHINE_ID: liveTestnetConfig.machineId, +}; + +function configuredValue(name) { + return process.env[name]?.trim() || defaults[name]; +} + const failures = []; for (const name of requiredUrls) { - const value = process.env[name]?.trim(); + const value = configuredValue(name); try { const url = new URL(value); if (url.protocol !== 'https:') failures.push(`${name} must use HTTPS.`); @@ -25,10 +43,10 @@ for (const name of requiredUrls) { } } for (const name of requiredAddresses) { - if (!/^0x[0-9a-fA-F]{40}$/.test(process.env[name]?.trim() ?? '')) + if (!/^0x[0-9a-fA-F]{40}$/.test(configuredValue(name))) failures.push(`${name} must be a 20-byte address.`); } -if (!/^0x[0-9a-fA-F]{64}$/.test(process.env.VITE_DEMO_MACHINE_ID?.trim() ?? '')) +if (!/^0x[0-9a-fA-F]{64}$/.test(configuredValue('VITE_DEMO_MACHINE_ID'))) failures.push('VITE_DEMO_MACHINE_ID must be a 32-byte machine ID.'); if (failures.length) {