From f039e174ee56070d4e19fdd1c97da032a929a6d4 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Sun, 13 Sep 2026 14:41:52 +0100 Subject: [PATCH] feat: build operator onboarding console --- apps/web/e2e/marketplace-checkout.spec.ts | 195 +++- apps/web/package.json | 2 +- apps/web/src/machine-metadata.ts | 24 + apps/web/src/marketplace.test.ts | 18 + apps/web/src/marketplace.ts | 56 +- apps/web/src/operator.test.ts | 186 ++++ apps/web/src/operator.ts | 471 +++++++++ apps/web/src/pages/OperatorPage.tsx | 1089 +++++++++++++++++++-- apps/web/src/styles.css | 616 ++++++++++++ apps/web/src/wallet/config.ts | 31 +- apps/web/tsconfig.test.json | 4 +- apps/worker/README.md | 17 +- apps/worker/src/http.test.ts | 66 +- apps/worker/src/http.ts | 155 ++- apps/worker/src/queue.ts | 25 +- apps/worker/src/store.test.ts | 18 + apps/worker/src/store.ts | 71 +- apps/worker/src/test-store.ts | 31 +- apps/worker/src/types.ts | 15 + 19 files changed, 2954 insertions(+), 136 deletions(-) create mode 100644 apps/web/src/operator.test.ts create mode 100644 apps/web/src/operator.ts diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts index 6657cce..a039336 100644 --- a/apps/web/e2e/marketplace-checkout.spec.ts +++ b/apps/web/e2e/marketplace-checkout.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { Interface } from 'ethers'; +import { Interface, keccak256, toUtf8Bytes } from 'ethers'; const machineId = '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc'; @@ -13,6 +13,7 @@ const machineInterface = new Interface([ const paymentInterface = new Interface([ 'function machineOffers(bytes32) view returns (address beneficiary,uint128 pricePerSecond,bool active)', 'function paymentToken() view returns (address)', + 'function owner() view returns (address)', ]); const tokenInterface = new Interface([ 'function decimals() view returns (uint8)', @@ -40,7 +41,9 @@ test('Explore, machine detail, and checkout form one verified journey', async ({ page.getByRole('heading', { name: 'Industrial Excavator' }), ).toBeVisible({ timeout: 15_000 }); await page.getByRole('link', { name: 'View machine' }).click(); - await expect(page).toHaveURL(new RegExp(`/machines/${machineId}$`)); + await expect(page).toHaveURL(new RegExp(`/machines/${machineId}$`), { + timeout: 15_000, + }); await expect(page.getByText('22-ton operating capacity')).toBeVisible(); const bookingLink = page.getByRole('link', { name: /Book machine time/ }); await expect(bookingLink).toHaveAttribute('href', `/rent/${machineId}`); @@ -129,6 +132,98 @@ test('proof explorer resolves an order ID and recomputes every cross-chain invar ).toBeVisible(); }); +test('operator metadata upload fails closed then resumes the confirmed onboarding step', async ({ + page, +}) => { + await installWallet(page); + await mockMarketplaceRpc(page); + const digest = `0x${'71'.repeat(32)}`; + const uri = `https://relay.invalid/metadata/${digest}`; + const commitment = keccak256(toUtf8Bytes(uri)); + let uploads = 0; + await page.route('https://relay.invalid/metadata', async (route) => { + uploads += 1; + if (uploads === 1) + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + error: { message: 'Metadata storage is temporarily unavailable.' }, + }), + }); + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + contentDigest: digest, + commitment, + uri, + document: { name: 'Autonomous Wheel Loader' }, + createdAt: '2026-09-13T12:00:00.000Z', + }), + }); + }); + await page.goto('/operator'); + await page + .getByRole('main') + .getByRole('button', { name: 'Connect wallet' }) + .click(); + await page.getByRole('button', { name: /Playwright Wallet/ }).click(); + await page.getByRole('button', { name: 'Close wallet dialog' }).click(); + await page.getByRole('button', { name: /Onboard machine/ }).click(); + await page.getByLabel('Permanent machine label').fill('fleet.loader.042'); + await page.getByLabel('Display name').fill('Autonomous Wheel Loader'); + await page.getByLabel('Category').fill('Construction'); + await page.getByLabel('City').fill('Lagos'); + await page.getByLabel('Country').fill('Nigeria'); + await page.getByLabel('Site / bay').fill('Lekki Yard · Bay 2'); + await page.getByLabel('Tariff (token units / second)').fill('2500'); + await page + .getByLabel('Description') + .fill('A proof-gated wheel loader for a construction site.'); + + await page + .getByRole('button', { name: /Upload metadata and lock commitment/ }) + .click(); + await expect( + page.getByText('Metadata storage is temporarily unavailable.'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Build verifiable metadata' }), + ).toBeVisible(); + + await page + .getByRole('button', { name: /Upload metadata and lock commitment/ }) + .click(); + await expect( + page.getByRole('heading', { name: 'Register the machine identity' }), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole('heading', { name: 'Register the machine identity' }), + ).toBeVisible(); + await page.evaluate(() => + localStorage.setItem('reject-next-transaction', 'yes'), + ); + await page.getByRole('button', { name: /Confirm CC3 registration/ }).click(); + await expect( + page.getByText('Wallet request cancelled. Nothing was charged.'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Register the machine identity' }), + ).toBeVisible(); + await page.getByRole('button', { name: /Confirm CC3 registration/ }).click(); + await expect( + page.getByRole('heading', { name: 'Synchronize the rental offer' }), + ).toBeVisible({ timeout: 15_000 }); + await page + .getByRole('button', { name: /Publish synchronized offer/ }) + .click(); + await expect(page.getByText('ONBOARDING COMPLETE')).toBeVisible({ + timeout: 15_000, + }); +}); + async function mockMarketplaceRpc(page: Page, includeUsage = false) { await page.route('https://**.rpc.proofkey.invalid/**', async (route) => { const request = route.request(); @@ -224,9 +319,11 @@ function rpcResult( true, ]); else if (target.endsWith('01')) - result = paymentInterface.encodeFunctionResult('paymentToken', [ - tokenAddress, - ]); + result = call.data.startsWith( + paymentInterface.getFunction('owner')!.selector, + ) + ? paymentInterface.encodeFunctionResult('owner', [owner]) + : paymentInterface.encodeFunctionResult('paymentToken', [tokenAddress]); else if ( call.data.startsWith(tokenInterface.getFunction('decimals')!.selector) ) @@ -407,9 +504,18 @@ async function installWallet(page: Page) { ({ approvedAccount }) => { type Listener = (...arguments_: unknown[]) => void; const listeners = new Map>(); + let chainId = '0xaa36a7'; + let transactionCount = 0; + const transactions = new Map>(); const provider = { - request: async ({ method }: { method: string }) => { - if (method === 'eth_chainId') return '0xaa36a7'; + request: async ({ + method, + params, + }: { + method: string; + params?: unknown[]; + }) => { + if (method === 'eth_chainId') return chainId; if (method === 'eth_accounts') return localStorage.getItem('approved') ? [approvedAccount] : []; if (method === 'eth_requestAccounts') { @@ -421,6 +527,81 @@ async function installWallet(page: Page) { ); return [approvedAccount]; } + if (method === 'wallet_switchEthereumChain') { + chainId = (params?.[0] as { chainId: string }).chainId; + queueMicrotask(() => + listeners + .get('chainChanged') + ?.forEach((listener) => listener(chainId)), + ); + return null; + } + if (method === 'wallet_addEthereumChain') { + chainId = (params?.[0] as { chainId: string }).chainId; + return null; + } + if (method === 'eth_sendTransaction') { + if (localStorage.getItem('reject-next-transaction')) { + localStorage.removeItem('reject-next-transaction'); + throw Object.assign(new Error('User rejected request'), { + code: 4001, + }); + } + transactionCount += 1; + const hash = `0x${transactionCount.toString(16).padStart(64, '0')}`; + const request = (params?.[0] ?? {}) as Record; + transactions.set(hash, request); + return hash; + } + if (method === 'eth_getTransactionByHash') { + const hash = params?.[0] as string; + const transaction = transactions.get(hash); + if (!transaction) return null; + return { + hash, + blockHash: `0x${'91'.repeat(32)}`, + blockNumber: '0x1', + transactionIndex: '0x0', + from: approvedAccount, + to: transaction.to, + input: transaction.data ?? '0x', + value: transaction.value ?? '0x0', + nonce: '0x0', + gas: '0x7a120', + gasPrice: '0x1', + type: '0x0', + chainId, + v: '0x1b', + r: `0x${'01'.repeat(32)}`, + s: `0x${'02'.repeat(32)}`, + }; + } + if (method === 'eth_getTransactionReceipt') { + const hash = params?.[0] as string; + const transaction = transactions.get(hash); + if (!transaction) return null; + return { + transactionHash: hash, + transactionIndex: '0x0', + blockHash: `0x${'91'.repeat(32)}`, + blockNumber: '0x1', + from: approvedAccount, + to: transaction.to, + cumulativeGasUsed: '0x5208', + gasUsed: '0x5208', + contractAddress: null, + logs: [], + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + effectiveGasPrice: '0x1', + type: '0x0', + }; + } + if (method === 'eth_blockNumber') return '0x2'; + if (method === 'eth_getTransactionCount') return '0x0'; + if (method === 'eth_estimateGas') return '0x7a120'; + if (method === 'eth_gasPrice') return '0x1'; + if (method === 'eth_maxPriorityFeePerGas') return '0x1'; throw Object.assign( new Error(`Unsupported wallet method: ${method}`), { code: 4200 }, diff --git a/apps/web/package.json b/apps/web/package.json index 33df8ec..8573943 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", + "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", "test:e2e": "playwright test", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/web/src/machine-metadata.ts b/apps/web/src/machine-metadata.ts index a42540a..94d0041 100644 --- a/apps/web/src/machine-metadata.ts +++ b/apps/web/src/machine-metadata.ts @@ -46,3 +46,27 @@ const metadataByUri = new Map([ export function allMachineMetadata() { return [...metadataByUri.values()]; } + +export function isMachineMetadata(value: unknown): value is MachineMetadata { + const item = value as Partial | undefined; + return Boolean( + item && + typeof item.uri === 'string' && + typeof item.name === 'string' && + typeof item.description === 'string' && + typeof item.image === 'string' && + typeof item.category === 'string' && + item.location && + typeof item.location.city === 'string' && + typeof item.location.country === 'string' && + typeof item.location.site === 'string' && + Array.isArray(item.capabilities) && + item.capabilities.every((entry) => typeof entry === 'string') && + Array.isArray(item.safetyRequirements) && + item.safetyRequirements.every((entry) => typeof entry === 'string') && + item.operator && + typeof item.operator.name === 'string' && + typeof item.operator.wallet === 'string' && + typeof item.operator.verified === 'boolean', + ); +} diff --git a/apps/web/src/marketplace.test.ts b/apps/web/src/marketplace.test.ts index c1cc685..93a3ac4 100644 --- a/apps/web/src/marketplace.test.ts +++ b/apps/web/src/marketplace.test.ts @@ -85,6 +85,24 @@ test('deterministic replay applies updates by chain position and rejects invalid assert.equal(listing?.status, 'metadata-invalid'); }); +test('active-state drift keeps a machine out of the rentable inventory', () => { + const inactive = { + ...recordedSepoliaOffer, + data: `${recordedSepoliaOffer.data.slice(0, -1)}0`, + }; + const [listing] = reconcileMarketplace( + replayMachineLogs([recordedCc3Registration]), + replayOfferLogs([inactive]), + { + address: '0x0000000000000000000000000000000000000001', + decimals: 6, + symbol: 'USDC', + }, + ); + assert.equal(listing?.synchronized, false); + assert.equal(listing?.status, 'unsynchronized'); +}); + test('filters, sorts, and paginates marketplace results', () => { const listings = reconcileMarketplace( replayMachineLogs([recordedCc3Registration]), diff --git a/apps/web/src/marketplace.ts b/apps/web/src/marketplace.ts index cca9482..359054c 100644 --- a/apps/web/src/marketplace.ts +++ b/apps/web/src/marketplace.ts @@ -8,6 +8,7 @@ import { } from 'ethers'; import { allMachineMetadata, + isMachineMetadata, type MachineMetadata, } from './machine-metadata.js'; @@ -18,6 +19,7 @@ interface MarketplaceConfig { registryAddress: string; creditcoinRegistryDeploymentBlock: number; sepoliaRegistryDeploymentBlock: number; + workerUrl: string; } export const machineRegistryEvents = new Interface([ @@ -171,10 +173,11 @@ export function reconcileMarketplace( registryMachines: ReadonlyMap, offers: ReadonlyMap, token: { address: string; decimals: number; symbol: string }, + metadataCatalog: readonly MachineMetadata[] = allMachineMetadata(), ) { return [...registryMachines.values()] .map((machine) => { - const metadata = allMachineMetadata().find((item) => + const metadata = metadataCatalog.find((item) => metadataMatches(item, machine.metadataHash), ); const offer = offers.get(machine.machineId); @@ -182,7 +185,8 @@ export function reconcileMarketplace( const synchronized = Boolean( offer && offer.beneficiary.toLowerCase() === machine.owner.toLowerCase() && - offer.pricePerSecond === machine.tariff, + offer.pricePerSecond === machine.tariff && + offer.active === machine.active, ); const status: MarketplaceStatus = !metadataValid ? 'metadata-invalid' @@ -356,14 +360,52 @@ export class MarketplaceClient { tokenContract.getFunction('decimals')(), tokenContract.getFunction('symbol')(), ]); + const metadataCatalog = [...allMachineMetadata()]; + const localCommitments = new Set( + metadataCatalog.map((item) => + keccak256(toUtf8Bytes(item.uri)).toLowerCase(), + ), + ); + const remote = await Promise.all( + [...machines.values()] + .filter( + (machine) => + !localCommitments.has(machine.metadataHash.toLowerCase()), + ) + .map((machine) => this.loadRemoteMetadata(machine.metadataHash)), + ); + metadataCatalog.push( + ...remote.filter((item): item is MachineMetadata => Boolean(item)), + ); return { - machines: reconcileMarketplace(machines, offers, { - address: tokenAddress, - decimals: Number(decimals), - symbol: symbol as string, - }), + machines: reconcileMarketplace( + machines, + offers, + { + address: tokenAddress, + decimals: Number(decimals), + symbol: symbol as string, + }, + metadataCatalog, + ), creditcoinBlock, sepoliaBlock, }; } + + private async loadRemoteMetadata(commitment: string) { + try { + const response = await fetch( + `${this.config.workerUrl}/metadata/commitments/${commitment}`, + ); + if (!response.ok) return undefined; + const metadata = (await response.json()) as unknown; + return isMachineMetadata(metadata) && + metadataMatches(metadata, commitment) + ? metadata + : undefined; + } catch { + return undefined; + } + } } diff --git a/apps/web/src/operator.test.ts b/apps/web/src/operator.test.ts new file mode 100644 index 0000000..18ce762 --- /dev/null +++ b/apps/web/src/operator.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { Eip1193Provider } from 'ethers'; +import { + OperatorClient, + assertOperatorAccount, + createOperatorSession, + loadOperatorSession, + machineIdFromLabel, + metadataDocument, + operatorStorageKey, + saveOperatorSession, + switchWalletChain, + updateOperatorSession, + validateMachineDraft, + type MachineDraft, +} from './operator.js'; +import type { AppConfig } from './contracts.js'; + +const owner = '0x1111111111111111111111111111111111111111'; +const draft: MachineDraft = { + label: 'fleet.loader.042', + name: 'Autonomous Wheel Loader', + description: 'A proof-gated loader for an active construction site.', + image: 'loader', + category: 'Construction', + city: 'Lagos', + country: 'Nigeria', + site: 'Lekki Yard · Bay 2', + capabilities: 'GPS telemetry, Remote controller', + safetyRequirements: 'Operator briefing, PPE', + controller: owner, + tariff: '2500', + active: true, +}; + +test('creates and resumes a deterministic partial onboarding session', () => { + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + const created = createOperatorSession(owner, draft); + const partial = updateOperatorSession(created, { + phase: 'metadata_uploaded', + metadata: { + contentDigest: `0x${'12'.repeat(32)}`, + commitment: `0x${'34'.repeat(32)}`, + uri: `https://relay.example/metadata/0x${'12'.repeat(32)}`, + document: { name: draft.name }, + createdAt: '2026-09-13T12:00:00.000Z', + }, + }); + saveOperatorSession(partial); + + assert.equal(loadOperatorSession(owner)?.phase, 'metadata_uploaded'); + assert.equal( + loadOperatorSession(owner)?.machineId, + machineIdFromLabel(draft.label), + ); + assert.match(operatorStorageKey(owner), /operator/); +}); + +test('validates onboarding and builds normalized metadata', () => { + assert.deepEqual(validateMachineDraft(draft), []); + assert.match(machineIdFromLabel(draft.label), /^0x[0-9a-f]{64}$/); + const metadata = metadataDocument(draft, owner); + assert.deepEqual(metadata.capabilities, [ + 'GPS telemetry', + 'Remote controller', + ]); + assert.equal(metadata.operator.wallet, owner); + assert.ok( + validateMachineDraft({ ...draft, controller: 'wrong', tariff: '0' }) + .length >= 2, + ); +}); + +test('wrong operator account is rejected before mutation', () => { + assert.throws( + () => + assertOperatorAccount( + owner, + '0x2222222222222222222222222222222222222222', + ), + /does not own this operator session/, + ); +}); + +test('metadata upload failure cannot produce a registered session', async () => { + const config = { + workerUrl: 'https://relay.example', + creditcoinRpcUrl: 'https://creditcoin.example', + sepoliaRpcUrl: 'https://sepolia.example', + registryAddress: '0x0000000000000000000000000000000000000001', + machineRegistryAddress: '0x0000000000000000000000000000000000000002', + } as AppConfig; + const client = new OperatorClient(config); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ error: { message: 'Durable storage unavailable.' } }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ); + try { + await assert.rejects( + client.uploadMetadata(draft, owner), + /Durable storage unavailable/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('network transition adds CC3 when missing and verifies the result', async () => { + let chainId = '0xaa36a7'; + let added = false; + const wallet = { + request: async ({ + method, + params, + }: { + method: string; + params?: unknown[]; + }) => { + if (method === 'wallet_switchEthereumChain') { + if (!added) + throw Object.assign(new Error('Unrecognized chain'), { code: 4902 }); + chainId = (params?.[0] as { chainId: string }).chainId; + return null; + } + if (method === 'wallet_addEthereumChain') { + added = true; + chainId = (params?.[0] as { chainId: string }).chainId; + return null; + } + if (method === 'eth_chainId') return chainId; + throw new Error(`Unexpected method ${method}`); + }, + } as Eip1193Provider; + await switchWalletChain(wallet, { + chainId: 102031, + chainName: 'Creditcoin CC3 Testnet', + rpcUrl: 'https://creditcoin.example', + explorerUrl: 'https://explorer.example', + nativeSymbol: 'CTC', + }); + assert.equal(chainId, '0x18e8f'); +}); + +test('wrong network and rejected switch remain explicit failures', async () => { + const stuck = { + request: async ({ method }: { method: string }) => + method === 'eth_chainId' ? '0xaa36a7' : null, + } as Eip1193Provider; + await assert.rejects( + switchWalletChain(stuck, { + chainId: 102031, + chainName: 'Creditcoin CC3 Testnet', + rpcUrl: 'https://creditcoin.example', + explorerUrl: 'https://explorer.example', + nativeSymbol: 'CTC', + }), + /did not switch/, + ); + + const rejected = { + request: async () => { + throw Object.assign(new Error('User rejected request'), { code: 4001 }); + }, + } as Eip1193Provider; + await assert.rejects( + switchWalletChain(rejected, { + chainId: 102031, + chainName: 'Creditcoin CC3 Testnet', + rpcUrl: 'https://creditcoin.example', + explorerUrl: 'https://explorer.example', + nativeSymbol: 'CTC', + }), + /User rejected/, + ); +}); diff --git a/apps/web/src/operator.ts b/apps/web/src/operator.ts new file mode 100644 index 0000000..8df59a6 --- /dev/null +++ b/apps/web/src/operator.ts @@ -0,0 +1,471 @@ +import { + BrowserProvider, + Contract, + JsonRpcProvider, + getAddress, + isAddress, + isHexString, + keccak256, + toUtf8Bytes, + type Eip1193Provider, +} from 'ethers'; +import type { AppConfig } from './contracts.js'; +import type { MachineMetadata } from './machine-metadata.js'; + +const machineRegistryAbi = [ + 'function machines(bytes32 machineId) view returns (address owner,address controller,bytes32 metadataHash,uint128 tariff,bool active)', + 'function registerMachine(bytes32 machineId,address controller,bytes32 metadataHash,uint128 tariff,bool active)', + 'function updateController(bytes32 machineId,address controller)', + 'function updateMetadataHash(bytes32 machineId,bytes32 metadataHash)', + 'function updateTariff(bytes32 machineId,uint128 tariff)', + 'function setMachineActive(bytes32 machineId,bool active)', +] as const; +const paymentRegistryAbi = [ + 'function owner() view returns (address)', + 'function setMachineOffer(bytes32 machineId,address beneficiary,uint128 pricePerSecond,bool active)', + 'function setMachineActive(bytes32 machineId,bool active)', +] as const; + +export type OperatorPhase = + 'draft' | 'metadata_uploaded' | 'cc3_confirmed' | 'complete'; + +export interface MachineDraft { + label: string; + name: string; + description: string; + image: string; + category: string; + city: string; + country: string; + site: string; + capabilities: string; + safetyRequirements: string; + controller: string; + tariff: string; + active: boolean; +} + +export interface UploadedMetadata { + contentDigest: string; + commitment: string; + uri: string; + document: Record; + createdAt: string; +} + +export interface OperatorSession { + version: 1; + account: string; + phase: OperatorPhase; + draft: MachineDraft; + machineId: string; + metadata?: UploadedMetadata; + creditcoinTransactionHash?: string; + sepoliaTransactionHash?: string; + updatedAt: string; +} + +export interface OperatorAuthority { + paymentRegistryOwner: string; +} + +export const emptyMachineDraft: MachineDraft = { + label: '', + name: '', + description: '', + image: 'industrial-machine', + category: '', + city: '', + country: '', + site: '', + capabilities: '', + safetyRequirements: '', + controller: '', + tariff: '', + active: true, +}; + +export class OperatorClient { + private readonly creditcoin: JsonRpcProvider; + private readonly sepolia: JsonRpcProvider; + + constructor(private readonly config: AppConfig) { + this.creditcoin = new JsonRpcProvider(config.creditcoinRpcUrl, 102031, { + staticNetwork: true, + }); + this.sepolia = new JsonRpcProvider(config.sepoliaRpcUrl, 11155111, { + staticNetwork: true, + }); + } + + async loadAuthority(): Promise { + const registry = new Contract( + this.config.registryAddress, + paymentRegistryAbi, + this.sepolia, + ); + return { + paymentRegistryOwner: getAddress( + (await registry.getFunction('owner')()) as string, + ), + }; + } + + async uploadMetadata( + draft: MachineDraft, + account: string, + ): Promise { + const errors = validateMachineDraft(draft); + if (errors.length) throw new Error(errors[0]); + const document = metadataDocument(draft, account); + const response = await fetch(`${this.config.workerUrl}/metadata`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document }), + }); + const body = (await response.json().catch(() => undefined)) as + UploadedMetadata | { error?: { message?: string } } | undefined; + if (!response.ok) + throw new Error( + body && 'error' in body + ? (body.error?.message ?? 'Metadata upload failed.') + : 'Metadata upload failed.', + ); + const uploaded = body as UploadedMetadata; + if ( + !isHexString(uploaded.contentDigest, 32) || + !isHexString(uploaded.commitment, 32) || + keccak256(toUtf8Bytes(uploaded.uri)).toLowerCase() !== + uploaded.commitment.toLowerCase() + ) + throw new Error('Metadata service returned an invalid commitment.'); + return uploaded; + } + + async registerMachine( + session: OperatorSession, + wallet: Eip1193Provider, + ): Promise { + if (!session.metadata) + throw new Error('Upload metadata before registration.'); + return this.creditcoinMutation(wallet, session.account, 'registerMachine', [ + session.machineId, + session.draft.controller, + session.metadata.commitment, + BigInt(session.draft.tariff), + session.draft.active, + ]); + } + + async synchronizeOffer( + session: OperatorSession, + wallet: Eip1193Provider, + ): Promise { + return this.sepoliaMutation(wallet, session.account, 'setMachineOffer', [ + session.machineId, + session.account, + BigInt(session.draft.tariff), + session.draft.active, + ]); + } + + updateController( + machineId: string, + controller: string, + account: string, + wallet: Eip1193Provider, + ) { + if (!isAddress(controller)) + throw new Error('Controller address is invalid.'); + return this.creditcoinMutation(wallet, account, 'updateController', [ + machineId, + controller, + ]); + } + + updateMetadata( + machineId: string, + commitment: string, + account: string, + wallet: Eip1193Provider, + ) { + if (!isHexString(commitment, 32)) + throw new Error('Metadata commitment is invalid.'); + return this.creditcoinMutation(wallet, account, 'updateMetadataHash', [ + machineId, + commitment, + ]); + } + + updateTariff( + machineId: string, + tariff: string, + account: string, + wallet: Eip1193Provider, + ) { + if (!positiveInteger(tariff)) throw new Error('Tariff must be positive.'); + return this.creditcoinMutation(wallet, account, 'updateTariff', [ + machineId, + BigInt(tariff), + ]); + } + + setCreditcoinActive( + machineId: string, + active: boolean, + account: string, + wallet: Eip1193Provider, + ) { + return this.creditcoinMutation(wallet, account, 'setMachineActive', [ + machineId, + active, + ]); + } + + repairOffer( + machineId: string, + beneficiary: string, + tariff: bigint, + active: boolean, + account: string, + wallet: Eip1193Provider, + ) { + return this.sepoliaMutation(wallet, account, 'setMachineOffer', [ + machineId, + beneficiary, + tariff, + active, + ]); + } + + private async creditcoinMutation( + wallet: Eip1193Provider, + account: string, + method: string, + arguments_: unknown[], + ) { + await switchWalletChain(wallet, { + chainId: 102031, + chainName: 'Creditcoin CC3 Testnet', + rpcUrl: this.config.creditcoinRpcUrl, + explorerUrl: this.config.creditcoinExplorerUrl, + nativeSymbol: 'CTC', + }); + return sendContractTransaction( + wallet, + account, + this.config.machineRegistryAddress, + machineRegistryAbi, + method, + arguments_, + ); + } + + private async sepoliaMutation( + wallet: Eip1193Provider, + account: string, + method: string, + arguments_: unknown[], + ) { + await switchWalletChain(wallet, { + chainId: 11155111, + chainName: 'Ethereum Sepolia', + rpcUrl: this.config.sepoliaRpcUrl, + explorerUrl: this.config.sepoliaExplorerUrl, + nativeSymbol: 'ETH', + }); + return sendContractTransaction( + wallet, + account, + this.config.registryAddress, + paymentRegistryAbi, + method, + arguments_, + ); + } +} + +export function createOperatorSession( + account: string, + draft: MachineDraft, +): OperatorSession { + const normalized = getAddress(account); + return { + version: 1, + account: normalized, + phase: 'draft', + draft, + machineId: machineIdFromLabel(draft.label), + updatedAt: new Date().toISOString(), + }; +} + +export function updateOperatorSession( + session: OperatorSession, + update: Partial>, +): OperatorSession { + return { ...session, ...update, updatedAt: new Date().toISOString() }; +} + +export function operatorStorageKey(account: string) { + return `proofkey:operator:${account.toLowerCase()}:onboarding`; +} + +export function saveOperatorSession(session: OperatorSession) { + localStorage.setItem( + operatorStorageKey(session.account), + JSON.stringify(session), + ); +} + +export function loadOperatorSession( + account: string, +): OperatorSession | undefined { + try { + const value = localStorage.getItem(operatorStorageKey(account)); + if (!value) return undefined; + const session = JSON.parse(value) as OperatorSession; + if ( + session.version !== 1 || + session.account.toLowerCase() !== account.toLowerCase() || + !isHexString(session.machineId, 32) + ) + return undefined; + return session; + } catch { + return undefined; + } +} + +export function clearOperatorSession(account: string) { + localStorage.removeItem(operatorStorageKey(account)); +} + +export function machineIdFromLabel(label: string) { + const normalized = label.trim().toLowerCase(); + if (normalized.length < 3) + throw new Error('Machine label must contain at least 3 characters.'); + return keccak256(toUtf8Bytes(normalized)); +} + +export function validateMachineDraft(draft: MachineDraft): string[] { + const errors: string[] = []; + if (draft.label.trim().length < 3) errors.push('Machine label is required.'); + if (draft.name.trim().length < 3) errors.push('Machine name is required.'); + if (draft.description.trim().length < 12) + errors.push('Add a useful machine description.'); + if (!draft.category.trim()) errors.push('Category is required.'); + if (!draft.city.trim() || !draft.country.trim() || !draft.site.trim()) + errors.push('Complete the machine location.'); + if (!isAddress(draft.controller)) + errors.push('Controller address is invalid.'); + if (!positiveInteger(draft.tariff)) + errors.push('Tariff must be a positive integer.'); + return errors; +} + +export function assertOperatorAccount(expected: string, actual: string) { + if (getAddress(expected) !== getAddress(actual)) + throw new Error('Connected account does not own this operator session.'); +} + +export function metadataDocument( + draft: MachineDraft, + account: string, +): Omit { + return { + name: draft.name.trim(), + description: draft.description.trim(), + image: draft.image.trim() || 'industrial-machine', + category: draft.category.trim(), + location: { + city: draft.city.trim(), + country: draft.country.trim(), + site: draft.site.trim(), + }, + capabilities: commaList(draft.capabilities), + safetyRequirements: commaList(draft.safetyRequirements), + operator: { + name: draft.name.trim(), + wallet: getAddress(account), + verified: true, + }, + }; +} + +interface ChainDefinition { + chainId: number; + chainName: string; + rpcUrl: string; + explorerUrl: string; + nativeSymbol: string; +} + +export async function switchWalletChain( + wallet: Eip1193Provider, + chain: ChainDefinition, +) { + const chainId = `0x${chain.chainId.toString(16)}`; + try { + await wallet.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }); + } catch (error) { + const candidate = error as { code?: number; message?: string }; + if ( + candidate.code !== 4902 && + !/unrecognized chain/i.test(candidate.message ?? '') + ) + throw error; + await wallet.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId, + chainName: chain.chainName, + nativeCurrency: { + name: chain.nativeSymbol, + symbol: chain.nativeSymbol, + decimals: 18, + }, + rpcUrls: [chain.rpcUrl], + blockExplorerUrls: [chain.explorerUrl], + }, + ], + }); + } + const current = await wallet.request({ method: 'eth_chainId' }); + if (BigInt(current as string) !== BigInt(chain.chainId)) + throw new Error(`Wallet did not switch to ${chain.chainName}.`); +} + +async function sendContractTransaction( + wallet: Eip1193Provider, + account: string, + address: string, + abi: readonly string[], + method: string, + arguments_: unknown[], +) { + const provider = new BrowserProvider(wallet); + const signer = await provider.getSigner(account); + assertOperatorAccount(account, await signer.getAddress()); + const contract = new Contract(address, abi, signer); + const transaction = await contract.getFunction(method)(...arguments_); + const receipt = await transaction.wait(); + if (!receipt || receipt.status !== 1) + throw new Error(`${method} transaction reverted.`); + return transaction.hash as string; +} + +function positiveInteger(value: string) { + return /^\d+$/.test(value) && BigInt(value) > 0n; +} + +function commaList(value: string) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} diff --git a/apps/web/src/pages/OperatorPage.tsx b/apps/web/src/pages/OperatorPage.tsx index 74ba21d..5c1ba9f 100644 --- a/apps/web/src/pages/OperatorPage.tsx +++ b/apps/web/src/pages/OperatorPage.tsx @@ -1,22 +1,80 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { + ArrowRight, + Blocks, Check, + CircleAlert, + Database, ExternalLink, + FileUp, + Gauge, + LoaderCircle, + Plus, + RefreshCw, Settings2, ShieldCheck, + SlidersHorizontal, + Wallet, Wrench, } from 'lucide-react'; -import { useMachineOffer, useRuntime } from '../app/AppProviders.js'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { useMarketplace, useRuntime } from '../app/AppProviders.js'; +import { DataState, PageHeading } from '../components/ProductUI.js'; +import type { MarketplaceMachine } from '../marketplace.js'; import { - DataState, - InvariantList, - PageHeading, - formatRate, -} from '../components/ProductUI.js'; -import { compactHash } from '../product.js'; + OperatorClient, + clearOperatorSession, + createOperatorSession, + emptyMachineDraft, + loadOperatorSession, + machineIdFromLabel, + saveOperatorSession, + updateOperatorSession, + validateMachineDraft, + type MachineDraft, + type OperatorSession, +} from '../operator.js'; +import { compactHash, describeError, machinePath } from '../product.js'; + +type View = 'inventory' | 'onboard'; export function Component() { - const { config, account } = useRuntime(); - const offer = useMachineOffer(); + const { config, account, walletProvider, openWallet } = useRuntime(); + const marketplace = useMarketplace(); + const queryClient = useQueryClient(); + const client = useMemo( + () => (config ? new OperatorClient(config) : undefined), + [config], + ); + const authority = useQuery({ + queryKey: ['operator-authority', config?.registryAddress], + queryFn: () => client!.loadAuthority(), + enabled: Boolean(client), + }); + const [view, setView] = useState('inventory'); + const [draft, setDraft] = useState(emptyMachineDraft); + const [session, setSession] = useState(); + const [busy, setBusy] = useState(); + const [error, setError] = useState(); + const [editing, setEditing] = useState(); + + useEffect(() => { + if (!account) { + setSession(undefined); + return; + } + const saved = loadOperatorSession(account); + setSession(saved); + setDraft( + saved?.draft ?? { + ...emptyMachineDraft, + controller: account, + }, + ); + if (saved) setView('onboard'); + }, [account]); + if (!config) return (
@@ -28,120 +86,959 @@ export function Component() {
); - const isOwner = Boolean( + const ownedMachines = (marketplace.data?.machines ?? []).filter( + (machine) => + account && machine.owner.toLowerCase() === account.toLowerCase(), + ); + const registryOwner = Boolean( account && - offer.data && - account.toLowerCase() === offer.data.beneficiary.toLowerCase(), + authority.data && + authority.data.paymentRegistryOwner.toLowerCase() === account.toLowerCase(), ); + + const saveSession = (next: OperatorSession) => { + saveOperatorSession(next); + setSession(next); + setDraft(next.draft); + }; + + const execute = async (label: string, operation: () => Promise) => { + setBusy(label); + setError(undefined); + try { + await operation(); + await queryClient.invalidateQueries({ queryKey: ['marketplace'] }); + } catch (reason) { + setError(describeError(reason)); + } finally { + setBusy(undefined); + } + }; + + const uploadMetadata = () => + execute('metadata', async () => { + if (!account || !client) throw new Error('Connect the operator wallet.'); + const next = session ?? createOperatorSession(account, draft); + const metadata = await client.uploadMetadata(draft, account); + saveSession( + updateOperatorSession(next, { + draft, + machineId: machineIdFromLabel(draft.label), + metadata, + phase: 'metadata_uploaded', + }), + ); + }); + + const registerMachine = () => + execute('creditcoin', async () => { + if (!session || !client || !walletProvider) + throw new Error('The uploaded onboarding session is not ready.'); + const transactionHash = await client.registerMachine( + session, + walletProvider, + ); + saveSession( + updateOperatorSession(session, { + creditcoinTransactionHash: transactionHash, + phase: 'cc3_confirmed', + }), + ); + }); + + const synchronizeOffer = () => + execute('sepolia', async () => { + if (!session || !client || !walletProvider) + throw new Error('The Creditcoin registration is not ready.'); + if (!registryOwner) + throw new Error( + `Sepolia offer updates require registry owner ${authority.data?.paymentRegistryOwner ?? 'unknown'}.`, + ); + const transactionHash = await client.synchronizeOffer( + session, + walletProvider, + ); + saveSession( + updateOperatorSession(session, { + sepoliaTransactionHash: transactionHash, + phase: 'complete', + }), + ); + }); + + const startNew = () => { + if (account) clearOperatorSession(account); + setSession(undefined); + setDraft({ ...emptyMachineDraft, controller: account ?? '' }); + setError(undefined); + }; + return ( -
+
+
- OWNER SESSION + OPERATOR SESSION - {isOwner - ? 'Verified' - : account - ? 'Read only' - : 'Wallet required'} + {account ? compactHash(account) : 'Wallet required'}
} /> -
-
-
- - {' '} - {offer.data?.active - ? 'Accepting rentals' - : 'Not accepting rentals'} - - + +
+ + +
+ + {error && ( +
+ + + Transaction not completed + {error} + + +
+ )} + + {!account ? ( +
+
+ +

Connect the owner wallet

+

+ Ownership is read directly from Creditcoin. Mutation controls stay + disabled until the matching account is connected. +

-

MACHINE / 001

-

{config.machineName}

- {compactHash(config.machineId, 14, 10)} -
-
- Current rate - - {offer.data ? formatRate(offer.data) : 'Loading…'} - + +
+ ) : view === 'inventory' ? ( + void marketplace.refetch()} + onManage={setEditing} + onRepair={(machine) => + void execute('repair', async () => { + if (!client || !walletProvider || !account) + throw new Error('Wallet is not ready.'); + if (!registryOwner) + throw new Error('Connect the Sepolia payment-registry owner.'); + await client.repairOffer( + machine.machineId, + machine.owner, + machine.tariff, + machine.active, + account, + walletProvider, + ); + }) + } + /> + ) : ( + void uploadMetadata()} + onRegister={() => void registerMachine()} + onSynchronize={() => void synchronizeOffer()} + onReset={startNew} + config={config} + /> + )} + + {editing && account && client && walletProvider && ( + setEditing(undefined)} + execute={execute} + /> + )} +
+ ); +} + +interface InventoryProps { + machines: MarketplaceMachine[]; + loading: boolean; + error: boolean; + registryOwner: boolean; + config: NonNullable['config']>; + busy?: string; + onRefresh: () => void; + onManage: (machine: MarketplaceMachine) => void; + onRepair: (machine: MarketplaceMachine) => void; +} + +function Inventory(props: InventoryProps) { + if (props.loading) + return ( + + ); + if (props.error) + return ( + + Retry + + } + /> + ); + if (!props.machines.length) + return ( + + ); + return ( +
+
+
+ LIVE INVENTORY +

Cross-chain machine control

+
+ +
+
+ {props.machines.map((machine) => ( +
+
+ + {statusLabel(machine)} + + +
+

+ {machine.metadata?.category ?? 'UNVERIFIED METADATA'} +

+

+ {machine.metadata?.name ?? compactHash(machine.machineId, 12, 8)} +

+ {compactHash(machine.machineId, 14, 10)} + +
+ + View profile + + + {!machine.synchronized && ( + + )}
+
+ ))} +
+
+ + MachineRegistry{' '} + {compactHash(props.config.machineRegistryAddress)} + + + + UsagePaymentRegistry{' '} + {compactHash(props.config.registryAddress)} + +
+
+ ); +} + +function DriftChecks({ machine }: { machine: MarketplaceMachine }) { + const checks = [ + [ + 'Owner / beneficiary', + machine.owner, + machine.offer?.beneficiary, + Boolean( + machine.offer && + machine.owner.toLowerCase() === machine.offer.beneficiary.toLowerCase(), + ), + ], + [ + 'Tariff', + machine.tariff.toString(), + machine.offer?.pricePerSecond.toString(), + machine.offer?.pricePerSecond === machine.tariff, + ], + [ + 'Active state', + String(machine.active), + machine.offer ? String(machine.offer.active) : undefined, + Boolean(machine.offer && machine.active === machine.offer.active), + ], + [ + 'Metadata digest', + machine.metadataHash, + machine.metadata?.uri, + machine.metadataValid, + ], + ] as const; + return ( +
+ {checks.map(([label, creditcoin, sepolia, ok]) => ( +
+ + {ok ? : } + {label} + + CC3 {compactHash(creditcoin, 10, 6)} + + SEP {sepolia ? compactHash(sepolia, 10, 6) : 'missing'} + +
+ ))} +
+ ); +} + +interface WizardProps { + draft: MachineDraft; + setDraft: React.Dispatch>; + session?: OperatorSession; + busy?: string; + registryOwner: boolean; + registryOwnerAddress?: string; + onUpload: () => void; + onRegister: () => void; + onSynchronize: () => void; + onReset: () => void; + config: NonNullable['config']>; +} + +function OnboardingWizard(props: WizardProps) { + const step = !props.session + ? 0 + : props.session.phase === 'metadata_uploaded' + ? 1 + : props.session.phase === 'cc3_confirmed' + ? 2 + : 3; + const errors = validateMachineDraft(props.draft); + const update = (key: keyof MachineDraft, value: string | boolean) => + props.setDraft((current) => ({ ...current, [key]: value })); + return ( +
+
+ {[ + ['Metadata', 'Upload content-addressed profile'], + ['Creditcoin', 'Register identity and policy'], + ['Sepolia', 'Publish commercial offer'], + ['Ready', 'Machine enters marketplace'], + ].map(([title, copy], index) => ( +
+ {index < step ? : index + 1}
- Controller state - {offer.isError ? 'Unavailable' : 'Synchronized'} + {title} + {copy}
-
-
-

CROSS-CHAIN READINESS

-

Deployment integrity

- +
+ {step === 0 ? ( + <> +
+
+ STEP 1 / MACHINE PROFILE +

Build verifiable metadata

+
+ +
+

+ The relay stores a canonical JSON document in PostgreSQL under its + content digest. The returned URI is committed on Creditcoin + exactly. +

+
+ update('label', value)} + placeholder="fleet.loader.042" + /> + update('name', value)} + placeholder="Autonomous Wheel Loader" + /> + update('category', value)} + placeholder="Construction" + /> + update('controller', value)} + placeholder="0x…" + mono + /> + update('city', value)} + placeholder="Lagos" + /> + update('country', value)} + placeholder="Nigeria" + /> + update('site', value)} + placeholder="Lekki Yard · Bay 2" + /> + update('tariff', value)} + placeholder="2500" + mono + /> +