From c032dd3786cd0368b91a656e288d1cf41d9ee29f Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Sun, 13 Sep 2026 12:47:57 +0100 Subject: [PATCH 1/2] feat: add wallet rental activity dashboard --- apps/web/e2e/marketplace-checkout.spec.ts | 208 ++++++++++- apps/web/package.json | 2 +- apps/web/playwright.config.ts | 2 + apps/web/src/activity.test.ts | 152 ++++++++ apps/web/src/activity.ts | 405 +++++++++++++++++++++ apps/web/src/app/AppProviders.tsx | 6 +- apps/web/src/contracts.ts | 64 ++++ apps/web/src/flow.ts | 3 + apps/web/src/pages/ActivityPage.tsx | 408 ++++++++++++++++++---- apps/web/src/pages/RentPage.tsx | 31 +- apps/web/src/styles.css | 183 ++++++++++ apps/web/src/vite-env.d.ts | 3 + apps/web/src/worker.ts | 6 + apps/web/tsconfig.test.json | 4 +- 14 files changed, 1391 insertions(+), 86 deletions(-) create mode 100644 apps/web/src/activity.test.ts create mode 100644 apps/web/src/activity.ts diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts index da4968d..4a0879d 100644 --- a/apps/web/e2e/marketplace-checkout.spec.ts +++ b/apps/web/e2e/marketplace-checkout.spec.ts @@ -18,6 +18,18 @@ const tokenInterface = new Interface([ 'function decimals() view returns (uint8)', 'function symbol() view returns (string)', ]); +const usageInterface = new Interface([ + 'event UsagePaid(bytes32 indexed orderId,bytes32 indexed machineId,address indexed payer,address beneficiary,uint64 startTime,uint64 duration,uint256 amount)', +]); +const accessInterface = new Interface([ + 'function accessCredentials(bytes32,address) view returns (bytes32 authorizationId,uint64 expiresAt)', + 'function isAuthorized(bytes32,address) view returns (bool)', +]); +const proofInterface = new Interface([ + 'function processedOrders(bytes32) view returns (bool)', + 'event ProofKeyAccessActivated(bytes32 indexed queryId,bytes32 indexed orderId,bytes32 indexed machineId,address payer,uint64 expiresAt)', +]); +const orderId = `0x${'ee'.repeat(32)}`; test('Explore, machine detail, and checkout form one verified journey', async ({ page, @@ -58,7 +70,42 @@ test('checkout fails closed when registry RPC is unavailable', async ({ await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); }); -async function mockMarketplaceRpc(page: Page) { +test('a fresh connected browser recovers active access from chain and relay state', async ({ + page, +}) => { + await installWallet(page); + await mockMarketplaceRpc(page, true); + await page.route('https://relay.invalid/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sourceTransactionHash: `0x${'fa'.repeat(32)}`, + phase: 'completed', + orderId, + machineId, + payer: owner, + accessExpiresAt: '2500', + creditcoinTransactionHash: `0x${'ab'.repeat(32)}`, + }), + }); + }); + await page.goto('/activity'); + await page + .getByRole('main') + .getByRole('button', { name: 'Connect wallet' }) + .click(); + await page.getByRole('button', { name: /Playwright Wallet/ }).click(); + await expect(page.getByText('Machine access active')).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole('link', { name: /Open access/ })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Download receipt JSON' }), + ).toBeVisible(); +}); + +async function mockMarketplaceRpc(page: Page, includeUsage = false) { await page.route('https://**.rpc.proofkey.invalid/**', async (route) => { const request = route.request(); const payload = request.postDataJSON() as RpcRequest | RpcRequest[]; @@ -67,7 +114,7 @@ async function mockMarketplaceRpc(page: Page) { const responses = requests.map((rpc) => ({ jsonrpc: '2.0', id: rpc.id, - result: rpcResult(rpc, isCreditcoin), + result: rpcResult(rpc, isCreditcoin, includeUsage), })); await route.fulfill({ status: 200, @@ -83,7 +130,11 @@ interface RpcRequest { params?: unknown[]; } -function rpcResult(rpc: RpcRequest, isCreditcoin: boolean) { +function rpcResult( + rpc: RpcRequest, + isCreditcoin: boolean, + includeUsage: boolean, +) { let result: unknown; if (rpc.method === 'eth_chainId') result = isCreditcoin ? '0x18e8f' : '0xaa36a7'; @@ -92,15 +143,40 @@ function rpcResult(rpc: RpcRequest, isCreditcoin: boolean) { else if (rpc.method === 'eth_getLogs') { const topics = (rpc.params?.[0] as { topics?: unknown[] })?.topics ?? []; const isUsageQuery = topics.length > 1 && topics[1] === null; - result = isUsageQuery - ? [] - : isCreditcoin - ? [cc3Registration()] - : [sepoliaOffer()]; + const firstTopic = Array.isArray(topics[0]) + ? (topics[0] as string[])[0] + : topics[0]; + result = + firstTopic === + proofInterface.getEvent('ProofKeyAccessActivated')!.topicHash + ? [activation()] + : isUsageQuery + ? includeUsage + ? [usagePaid()] + : [] + : isCreditcoin + ? [cc3Registration()] + : [sepoliaOffer()]; + } else if (rpc.method === 'eth_getBlockByNumber') { + result = creditcoinBlock(); } else if (rpc.method === 'eth_call') { const call = rpc.params?.[0] as { to: string; data: string }; const target = call.to.toLowerCase(); - if (target.endsWith('02')) + if (target.endsWith('05')) + result = proofInterface.encodeFunctionResult('processedOrders', [true]); + else if ( + target.endsWith('04') && + call.data.startsWith( + accessInterface.getFunction('accessCredentials')!.selector, + ) + ) + result = accessInterface.encodeFunctionResult('accessCredentials', [ + orderId, + 2500, + ]); + else if (target.endsWith('04')) + result = accessInterface.encodeFunctionResult('isAuthorized', [true]); + else if (target.endsWith('02')) result = machineInterface.encodeFunctionResult('machines', [ owner, owner, @@ -132,6 +208,120 @@ function rpcResult(rpc: RpcRequest, isCreditcoin: boolean) { return result; } +function usagePaid() { + const encoded = usageInterface.encodeEventLog( + usageInterface.getEvent('UsagePaid')!, + [orderId, machineId, owner, owner, 1500, 1000, 2_500_000], + ); + return { + address: '0x0000000000000000000000000000000000000001', + blockHash: `0x${'a1'.repeat(32)}`, + blockNumber: '0xb26bfe', + transactionHash: `0x${'fa'.repeat(32)}`, + transactionIndex: '0x0', + logIndex: '0x0', + removed: false, + topics: encoded.topics, + data: encoded.data, + }; +} + +function activation() { + const encoded = proofInterface.encodeEventLog( + proofInterface.getEvent('ProofKeyAccessActivated')!, + [`0x${'12'.repeat(32)}`, orderId, machineId, owner, 2500], + ); + return { + address: '0x0000000000000000000000000000000000000005', + blockHash: `0x${'a2'.repeat(32)}`, + blockNumber: '0x539010', + transactionHash: `0x${'ab'.repeat(32)}`, + transactionIndex: '0x0', + logIndex: '0x0', + removed: false, + topics: encoded.topics, + data: encoded.data, + }; +} + +function creditcoinBlock() { + return { + number: '0x539100', + hash: `0x${'01'.repeat(32)}`, + parentHash: `0x${'02'.repeat(32)}`, + nonce: '0x0000000000000000', + sha3Uncles: `0x${'03'.repeat(32)}`, + logsBloom: `0x${'00'.repeat(256)}`, + transactionsRoot: `0x${'04'.repeat(32)}`, + stateRoot: `0x${'05'.repeat(32)}`, + receiptsRoot: `0x${'06'.repeat(32)}`, + miner: owner, + difficulty: '0x0', + totalDifficulty: '0x0', + extraData: '0x', + size: '0x1', + gasLimit: '0x1c9c380', + gasUsed: '0x0', + timestamp: '0x7d0', + transactions: [], + uncles: [], + baseFeePerGas: '0x1', + }; +} + +async function installWallet(page: Page) { + await page.addInitScript( + ({ approvedAccount }) => { + type Listener = (...arguments_: unknown[]) => void; + const listeners = new Map>(); + const provider = { + request: async ({ method }: { method: string }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + if (method === 'eth_accounts') + return localStorage.getItem('approved') ? [approvedAccount] : []; + if (method === 'eth_requestAccounts') { + localStorage.setItem('approved', 'yes'); + queueMicrotask(() => + listeners + .get('accountsChanged') + ?.forEach((listener) => listener([approvedAccount])), + ); + return [approvedAccount]; + } + throw Object.assign( + new Error(`Unsupported wallet method: ${method}`), + { code: 4200 }, + ); + }, + on: (event: string, listener: Listener) => { + const set = listeners.get(event) ?? new Set(); + set.add(listener); + listeners.set(event, set); + }, + removeListener: (event: string, listener: Listener) => + listeners.get(event)?.delete(listener), + }; + const detail = { + info: { + uuid: '450670db-19fa-4704-a166-e52e178b59d2', + name: 'Playwright Wallet', + icon: 'data:image/svg+xml,', + rdns: 'io.proofkey.activity', + }, + provider, + }; + const announce = () => + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { detail }), + ); + Object.defineProperty(window, 'ethereum', { value: provider }); + window.addEventListener('eip6963:requestProvider', announce); + queueMicrotask(announce); + }, + { approvedAccount: owner }, + ); +} + function cc3Registration() { return { address: '0x0000000000000000000000000000000000000002', diff --git a/apps/web/package.json b/apps/web/package.json index f26e93a..2cf1f6d 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", + "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js", "test:e2e": "playwright test", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 7306af3..51eb657 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -30,6 +30,8 @@ export default defineConfig({ '0x0000000000000000000000000000000000000001', VITE_MACHINE_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000002', + VITE_ACCESS_PASS_ADDRESS: '0x0000000000000000000000000000000000000004', + VITE_PROOFKEY_ASC_ADDRESS: '0x0000000000000000000000000000000000000005', VITE_DEMO_MACHINE_ID: '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc', VITE_PROOF_WORKER_URL: 'https://relay.invalid', diff --git a/apps/web/src/activity.test.ts b/apps/web/src/activity.test.ts new file mode 100644 index 0000000..5d0af77 --- /dev/null +++ b/apps/web/src/activity.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { UsageActivity } from './contracts.js'; +import type { RelayJob } from './flow.js'; +import { + correlateRental, + mergeOptimisticRentals, + paginateRentals, + readOptimisticRentals, + type CorrelationState, +} from './activity.js'; + +const orderId = `0x${'11'.repeat(32)}`; +const machineId = `0x${'22'.repeat(32)}`; +const sourceTransactionHash = `0x${'33'.repeat(32)}`; +const payer = `0x${'44'.repeat(20)}`; +const payment: UsageActivity = { + orderId, + machineId, + transactionHash: sourceTransactionHash, + payer, + beneficiary: `0x${'55'.repeat(20)}`, + startTime: 900n, + duration: 500n, + amount: 1_250_000n, + blockNumber: 100, +}; +const chain: CorrelationState = { + processed: false, + authorizationId: `0x${'00'.repeat(32)}`, + accessExpiresAt: 0, + authorized: false, + creditcoinTimestamp: 1_000, +}; +const queued: RelayJob = { + sourceTransactionHash, + phase: 'queued', + orderId, + machineId, + payer, +}; + +test('correlates pending through active and expired using Creditcoin time', () => { + assert.equal(correlateRental(payment, queued, chain).status, 'pending'); + const active = correlateRental( + payment, + { ...queued, phase: 'completed' }, + { + ...chain, + processed: true, + authorizationId: orderId, + accessExpiresAt: 1_400, + authorized: true, + activationTransactionHash: `0x${'66'.repeat(32)}`, + }, + ); + assert.equal(active.status, 'active'); + assert.equal(active.expiresAt, 1_400); + const expired = correlateRental( + payment, + { ...queued, phase: 'completed' }, + { + ...chain, + processed: true, + authorizationId: orderId, + accessExpiresAt: 1_400, + authorized: false, + creditcoinTimestamp: 1_401, + activationTransactionHash: `0x${'66'.repeat(32)}`, + }, + ); + assert.equal(expired.status, 'expired'); +}); + +test('classifies missing and failed relay work with an explicit next action', () => { + const missing = correlateRental(payment, undefined, chain); + assert.equal(missing.status, 'action-required'); + assert.match(missing.nextAction, /Resume/); + const failed = correlateRental( + payment, + { ...queued, phase: 'failed', error: 'proof unavailable' }, + chain, + ); + assert.equal(failed.status, 'failed'); + assert.equal(failed.diagnostic, 'proof unavailable'); +}); + +test('fails closed when relay and on-chain correlations conflict', () => { + const record = correlateRental( + payment, + { ...queued, orderId: `0x${'99'.repeat(32)}` }, + chain, + ); + assert.equal(record.status, 'action-required'); + assert.equal(record.stateLabel, 'Correlation conflict'); + assert.match(record.diagnostic ?? '', /order ID conflicts/); +}); + +test('optimistic payments disappear when authoritative chain history arrives', () => { + const authoritative = correlateRental(payment, queued, chain); + const optimistic = [ + { + machineId, + sourceTransactionHash, + durationSeconds: 500, + account: payer, + phase: 'confirming' as const, + updatedAt: new Date().toISOString(), + }, + ]; + assert.equal(mergeOptimisticRentals([authoritative], optimistic).length, 1); + assert.equal(mergeOptimisticRentals([], optimistic)[0]?.optimistic, true); +}); + +test('local pending recovery is wallet-scoped and pagination is stable', () => { + const other = `0x${'77'.repeat(20)}`; + const values = [ + JSON.stringify({ + version: 1, + machineId, + durationSeconds: 500, + phase: 'confirming', + account: payer, + sourceTransactionHash, + updatedAt: new Date().toISOString(), + }), + JSON.stringify({ + version: 1, + machineId, + durationSeconds: 500, + phase: 'confirming', + account: other, + sourceTransactionHash: `0x${'88'.repeat(32)}`, + updatedAt: new Date().toISOString(), + }), + ]; + const storage = { + length: values.length, + key: (index: number) => `proofkey:0xregistry:${index}:source-transaction`, + getItem: (key: string) => values[Number(key.split(':')[2])] ?? null, + }; + const recovered = readOptimisticRentals(storage, '0xregistry', payer); + assert.equal(recovered.length, 1); + assert.equal(recovered[0]?.account, payer); + const records = Array.from({ length: 7 }, (_, index) => ({ + ...correlateRental(payment, queued, chain), + sourceTransactionHash: `0x${String(index).padStart(64, '0')}`, + })); + const result = paginateRentals(records, 'pending', 2, 6); + assert.equal(result.items.length, 1); + assert.equal(result.page, 2); +}); diff --git a/apps/web/src/activity.ts b/apps/web/src/activity.ts new file mode 100644 index 0000000..c07cb99 --- /dev/null +++ b/apps/web/src/activity.ts @@ -0,0 +1,405 @@ +import { Contract, Interface, JsonRpcProvider, getAddress } from 'ethers'; +import type { AppConfig, PaymentClient, UsageActivity } from './contracts.js'; +import type { RelayJob } from './flow.js'; +import { isTransactionHash } from './product.js'; +import type { RentalSession } from './rental.js'; +import { ProofWorkerClient } from './worker.js'; + +const proofKeyAbi = [ + 'function processedOrders(bytes32 orderId) view returns (bool)', +] as const; +const accessPassAbi = [ + 'function accessCredentials(bytes32 machineId,address beneficiary) view returns (bytes32 authorizationId,uint64 expiresAt)', + 'function isAuthorized(bytes32 machineId,address beneficiary) view returns (bool)', +] as const; +const activationEvents = new Interface([ + 'event ProofKeyAccessActivated(bytes32 indexed queryId,bytes32 indexed orderId,bytes32 indexed machineId,address payer,uint64 expiresAt)', +]); + +export type RentalLifecycle = + 'pending' | 'active' | 'expired' | 'failed' | 'action-required'; + +export interface OptimisticRental { + machineId: string; + sourceTransactionHash: string; + durationSeconds: number; + account: string; + phase: RentalSession['phase']; + updatedAt: string; +} + +export interface RentalRecord { + orderId?: string; + machineId: string; + payer: string; + beneficiary?: string; + sourceTransactionHash: string; + creditcoinTransactionHash?: string; + sourceBlockNumber?: number; + startTime?: number; + expiresAt?: number; + durationSeconds: number; + amount?: string; + status: RentalLifecycle; + stateLabel: string; + nextAction: string; + relayPhase?: RelayJob['phase']; + diagnostic?: string; + optimistic: boolean; +} + +export interface ActivitySnapshot { + records: RentalRecord[]; + creditcoinTimestamp: number; + indexedAt: string; +} + +export interface CorrelationState { + processed: boolean; + authorizationId: string; + accessExpiresAt: number; + authorized: boolean; + activationTransactionHash?: string; + creditcoinTimestamp: number; +} + +export function correlateRental( + payment: UsageActivity, + job: RelayJob | undefined, + chain: CorrelationState, +): RentalRecord { + const sourceExpiry = Number(payment.startTime + payment.duration); + const conflict = correlationConflict(payment, job, chain); + if (conflict) + return baseRecord(payment, job, chain, { + status: 'action-required', + stateLabel: 'Correlation conflict', + nextAction: 'Inspect both transactions before taking action', + diagnostic: conflict, + }); + if (chain.processed) { + const ownsCurrentCredential = + chain.authorizationId.toLowerCase() === payment.orderId.toLowerCase(); + if ( + ownsCurrentCredential && + chain.authorized && + chain.accessExpiresAt > chain.creditcoinTimestamp + ) + return baseRecord(payment, job, chain, { + status: 'active', + stateLabel: 'Machine access active', + nextAction: 'Open the device terminal', + }); + return baseRecord(payment, job, chain, { + status: 'expired', + stateLabel: ownsCurrentCredential + ? 'Access expired' + : 'Access superseded', + nextAction: 'Rent this machine again', + }); + } + if (job?.phase === 'failed') + return baseRecord(payment, job, chain, { + status: 'failed', + stateLabel: 'Proof relay failed', + nextAction: 'Resume the proof relay', + diagnostic: job.error ?? 'The relay reported a failed job.', + }); + if (sourceExpiry <= chain.creditcoinTimestamp) + return baseRecord(payment, job, chain, { + status: 'failed', + stateLabel: 'Payment window expired before access', + nextAction: 'Start a new rental', + }); + if (job) + return baseRecord(payment, job, chain, { + status: 'pending', + stateLabel: relayLabel(job.phase), + nextAction: 'Track the proof or wait for completion', + }); + return baseRecord(payment, job, chain, { + status: 'action-required', + stateLabel: 'Payment needs proof submission', + nextAction: 'Resume this rental to enqueue the proof', + }); +} + +function baseRecord( + payment: UsageActivity, + job: RelayJob | undefined, + chain: CorrelationState, + lifecycle: Pick< + RentalRecord, + 'status' | 'stateLabel' | 'nextAction' | 'diagnostic' + >, +): RentalRecord { + return { + orderId: payment.orderId, + machineId: payment.machineId, + payer: payment.payer, + beneficiary: payment.beneficiary, + sourceTransactionHash: payment.transactionHash, + creditcoinTransactionHash: + job?.creditcoinTransactionHash ?? chain.activationTransactionHash, + sourceBlockNumber: payment.blockNumber, + startTime: Number(payment.startTime), + expiresAt: + chain.accessExpiresAt || Number(payment.startTime + payment.duration), + durationSeconds: Number(payment.duration), + amount: payment.amount.toString(), + relayPhase: job?.phase, + optimistic: false, + ...lifecycle, + }; +} + +function correlationConflict( + payment: UsageActivity, + job: RelayJob | undefined, + chain: CorrelationState, +) { + if ( + job && + job.sourceTransactionHash.toLowerCase() !== + payment.transactionHash.toLowerCase() + ) + return 'Relay source transaction does not match the indexed Sepolia receipt.'; + if ( + job?.orderId && + job.orderId.toLowerCase() !== payment.orderId.toLowerCase() + ) + return 'Relay order ID conflicts with the UsagePaid event.'; + if ( + job?.machineId && + job.machineId.toLowerCase() !== payment.machineId.toLowerCase() + ) + return 'Relay machine ID conflicts with the UsagePaid event.'; + if (job?.payer && job.payer.toLowerCase() !== payment.payer.toLowerCase()) + return 'Relay payer conflicts with the connected wallet history.'; + if (chain.processed && !chain.activationTransactionHash) + return 'Creditcoin reports the order processed but no activation event was found.'; + if ( + (job?.phase === 'completed' || job?.phase === 'duplicate') && + !chain.processed + ) + return 'Relay reports completion but the Creditcoin replay guard is not set.'; + return undefined; +} + +function relayLabel(phase: RelayJob['phase']) { + return { + queued: 'Queued for verification', + source_confirmation: 'Confirming Sepolia receipt', + attestation_wait: 'Waiting for Attestcoin coverage', + proof_generation: 'Building inclusion proof', + creditcoin_execution: 'Executing on Creditcoin', + completed: 'Creditcoin execution confirmed', + duplicate: 'Previously verified', + failed: 'Proof relay failed', + }[phase]; +} + +export function mergeOptimisticRentals( + records: RentalRecord[], + optimistic: readonly OptimisticRental[], +) { + const known = new Set( + records.map((item) => item.sourceTransactionHash.toLowerCase()), + ); + return [ + ...optimistic + .filter((item) => !known.has(item.sourceTransactionHash.toLowerCase())) + .map((item) => ({ + machineId: item.machineId, + payer: getAddress(item.account), + sourceTransactionHash: item.sourceTransactionHash, + durationSeconds: item.durationSeconds, + status: 'pending', + stateLabel: 'Waiting for Sepolia confirmation', + nextAction: 'Return to checkout to recover the payment', + optimistic: true, + })), + ...records, + ]; +} + +export function readOptimisticRentals( + storage: Pick, + registry: string, + account: string, +) { + const prefix = `proofkey:${registry.toLowerCase()}:`; + const rentals: OptimisticRental[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key?.startsWith(prefix)) continue; + try { + const session = JSON.parse(storage.getItem(key) ?? '') as RentalSession; + if ( + !session.sourceTransactionHash || + !session.account || + !isTransactionHash(session.sourceTransactionHash) + ) + continue; + if (session.account.toLowerCase() !== account.toLowerCase()) continue; + rentals.push({ + machineId: session.machineId, + sourceTransactionHash: session.sourceTransactionHash, + durationSeconds: session.durationSeconds, + account: session.account, + phase: session.phase, + updatedAt: session.updatedAt, + }); + } catch { + /* Ignore legacy and malformed browser hints. */ + } + } + return rentals; +} + +export function paginateRentals( + records: readonly RentalRecord[], + status: 'all' | RentalLifecycle, + page: number, + pageSize: number, +) { + const filtered = + status === 'all' + ? [...records] + : records.filter((item) => item.status === status); + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + return { + items: filtered.slice((safePage - 1) * pageSize, safePage * pageSize), + total: filtered.length, + page: safePage, + totalPages, + }; +} + +export class ActivityClient { + private readonly creditcoin: JsonRpcProvider; + private readonly worker: ProofWorkerClient; + constructor( + private readonly config: AppConfig, + private readonly payments: PaymentClient, + ) { + this.creditcoin = new JsonRpcProvider(config.creditcoinRpcUrl, 102031, { + staticNetwork: true, + }); + this.worker = new ProofWorkerClient(config.workerUrl); + } + + async load( + account: string, + optimistic: readonly OptimisticRental[], + ): Promise { + const payments = await this.payments.loadAccountUsage(account); + const duplicates = duplicateOrders(payments); + const latestNumber = await this.creditcoin.getBlockNumber(); + const latestBlock = await this.creditcoin.getBlock(latestNumber); + if (!latestBlock) + throw new Error('Creditcoin latest block is unavailable.'); + const orderIds = [...new Set(payments.map((item) => item.orderId))]; + const activationLogs = orderIds.length + ? await this.creditcoin.getLogs({ + address: this.config.proofKeyAscAddress, + topics: [ + activationEvents.getEvent('ProofKeyAccessActivated')!.topicHash, + null, + orderIds, + ], + fromBlock: this.config.proofKeyAscDeploymentBlock, + toBlock: latestNumber, + }) + : []; + const activations = new Map( + activationLogs.map((log) => { + const event = activationEvents.parseLog(log)!; + return [ + (event.args.orderId as string).toLowerCase(), + log.transactionHash, + ]; + }), + ); + const proofKey = new Contract( + this.config.proofKeyAscAddress, + proofKeyAbi, + this.creditcoin, + ); + const accessPass = new Contract( + this.config.accessPassAddress, + accessPassAbi, + this.creditcoin, + ); + const records = await Promise.all( + payments.map(async (payment) => { + const [jobResult, processed, credential, authorized] = + await Promise.all([ + this.worker + .find(payment.transactionHash) + .then((job) => ({ job })) + .catch((error: unknown) => ({ error })), + proofKey.getFunction('processedOrders')( + payment.orderId, + ) as Promise, + accessPass.getFunction('accessCredentials')( + payment.machineId, + account, + ), + accessPass.getFunction('isAuthorized')( + payment.machineId, + account, + ) as Promise, + ]); + const job = 'job' in jobResult ? jobResult.job : undefined; + const record = correlateRental(payment, job, { + processed, + authorizationId: credential.authorizationId as string, + accessExpiresAt: Number(credential.expiresAt as bigint), + authorized, + activationTransactionHash: activations.get( + payment.orderId.toLowerCase(), + ), + creditcoinTimestamp: latestBlock.timestamp, + }); + if ( + 'error' in jobResult && + record.status !== 'active' && + record.status !== 'expired' + ) { + record.status = 'action-required'; + record.stateLabel = 'Relay service unavailable'; + record.nextAction = 'Retry after the relay reconnects'; + record.diagnostic = + 'The source payment is indexed, but relay state could not be fetched.'; + } + if (duplicates.has(payment.orderId.toLowerCase())) { + record.status = 'action-required'; + record.stateLabel = 'Duplicate order correlation'; + record.nextAction = 'Inspect the duplicated source receipts'; + record.diagnostic = + 'More than one UsagePaid event resolved to the same replay-protected order ID.'; + } + return record; + }), + ); + return { + records: mergeOptimisticRentals(records, optimistic), + creditcoinTimestamp: latestBlock.timestamp, + indexedAt: new Date().toISOString(), + }; + } +} + +function duplicateOrders(payments: readonly UsageActivity[]) { + const counts = new Map(); + for (const payment of payments) + counts.set( + payment.orderId.toLowerCase(), + (counts.get(payment.orderId.toLowerCase()) ?? 0) + 1, + ); + return new Set( + [...counts].filter(([, count]) => count > 1).map(([orderId]) => orderId), + ); +} diff --git a/apps/web/src/app/AppProviders.tsx b/apps/web/src/app/AppProviders.tsx index bbd9827..e306440 100644 --- a/apps/web/src/app/AppProviders.tsx +++ b/apps/web/src/app/AppProviders.tsx @@ -29,6 +29,7 @@ import { type MachineOffer, type UsageActivity, } from '../contracts.js'; +import { ActivityClient } from '../activity.js'; import { describeError } from '../product.js'; import { MarketplaceClient, type MarketplaceSnapshot } from '../marketplace.js'; import { appKit, hasWalletConnect, wagmiConfig } from '../wallet/config.js'; @@ -43,6 +44,7 @@ interface RuntimeContextValue { config?: AppConfig; paymentClient?: PaymentClient; marketplaceClient?: MarketplaceClient; + activityClient?: ActivityClient; walletProvider?: Eip1193Provider; configurationError?: string; account?: string; @@ -87,10 +89,12 @@ function RuntimeProvider({ children }: { children: ReactNode }) { const runtime = useMemo(() => { try { const config = loadAppConfig(import.meta.env); + const paymentClient = new PaymentClient(config); return { config, - paymentClient: new PaymentClient(config), + paymentClient, marketplaceClient: new MarketplaceClient(config), + activityClient: new ActivityClient(config, paymentClient), }; } catch (error) { return { configurationError: describeError(error) }; diff --git a/apps/web/src/contracts.ts b/apps/web/src/contracts.ts index f3cc46d..c1c1cef 100644 --- a/apps/web/src/contracts.ts +++ b/apps/web/src/contracts.ts @@ -47,6 +47,9 @@ export interface AppConfig { machineLocation: string; creditcoinRegistryDeploymentBlock: number; sepoliaRegistryDeploymentBlock: number; + accessPassAddress: string; + proofKeyAscAddress: string; + proofKeyAscDeploymentBlock: number; } export interface MachineOffer { @@ -62,6 +65,7 @@ export interface PaymentResult { transactionHash: string; orderId: string; startTime: bigint; + duration: bigint; expiresAt: bigint; } @@ -80,6 +84,7 @@ export interface CheckoutSnapshot { export interface UsageActivity { orderId: string; + machineId: string; transactionHash: string; payer: string; beneficiary: string; @@ -126,6 +131,16 @@ export function loadAppConfig(environment: ImportMetaEnv): AppConfig { environment.VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK, 11_691_302, ), + accessPassAddress: + environment.VITE_ACCESS_PASS_ADDRESS?.trim() ?? + '0xa2D8dECC5665Fc3B969A58dBCe7Ff05E074127AA', + proofKeyAscAddress: + environment.VITE_PROOFKEY_ASC_ADDRESS?.trim() ?? + '0x79fA79C1fdc7eFaA75Bc039CdbdFc1ce109775e7', + proofKeyAscDeploymentBlock: parseDeploymentBlock( + environment.VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK, + 5_476_974, + ), }; if (!config.sepoliaRpcUrl) throw new Error('Set VITE_ETHEREUM_SEPOLIA_RPC_URL to load the machine.'); @@ -137,12 +152,18 @@ export function loadAppConfig(environment: ImportMetaEnv): AppConfig { throw new Error('Set a valid VITE_USAGE_PAYMENT_REGISTRY_ADDRESS.'); if (!isAddress(config.machineRegistryAddress)) throw new Error('Set a valid VITE_MACHINE_REGISTRY_ADDRESS.'); + if (!isAddress(config.accessPassAddress)) + throw new Error('Set a valid VITE_ACCESS_PASS_ADDRESS.'); + if (!isAddress(config.proofKeyAscAddress)) + throw new Error('Set a valid VITE_PROOFKEY_ASC_ADDRESS.'); if (!isHexString(config.machineId, 32)) throw new Error('Set VITE_DEMO_MACHINE_ID to a bytes32 value.'); return { ...config, registryAddress: getAddress(config.registryAddress), machineRegistryAddress: getAddress(config.machineRegistryAddress), + accessPassAddress: getAddress(config.accessPassAddress), + proofKeyAscAddress: getAddress(config.proofKeyAscAddress), }; } @@ -398,6 +419,7 @@ export class PaymentClient { return [ { orderId: event.args.orderId as string, + machineId: event.args.machineId as string, transactionHash: log.transactionHash, payer: getAddress(event.args.payer as string), beneficiary: getAddress(event.args.beneficiary as string), @@ -410,6 +432,47 @@ export class PaymentClient { }); } + async loadAccountUsage(payer: string): Promise { + const latest = await this.readProvider.getBlockNumber(); + const payerTopic = `0x${'0'.repeat(24)}${getAddress(payer).slice(2).toLowerCase()}`; + const logs = await this.readProvider.getLogs({ + address: this.config.registryAddress, + topics: [ + registryInterface.getEvent('UsagePaid')!.topicHash, + null, + null, + payerTopic, + ], + fromBlock: this.config.sepoliaRegistryDeploymentBlock, + toBlock: latest, + }); + return logs + .map((log) => this.usageFromLog(log)) + .filter((item): item is UsageActivity => Boolean(item)) + .sort((a, b) => b.blockNumber - a.blockNumber); + } + + private usageFromLog(log: { + topics: readonly string[]; + data: string; + transactionHash: string; + blockNumber: number; + }) { + const event = registryInterface.parseLog(log); + if (!event || event.name !== 'UsagePaid') return undefined; + return { + orderId: event.args.orderId as string, + machineId: event.args.machineId as string, + transactionHash: log.transactionHash, + payer: getAddress(event.args.payer as string), + beneficiary: getAddress(event.args.beneficiary as string), + startTime: event.args.startTime as bigint, + duration: event.args.duration as bigint, + amount: event.args.amount as bigint, + blockNumber: log.blockNumber, + } satisfies UsageActivity; + } + private paymentFromReceipt( receipt: { status: number | null; @@ -432,6 +495,7 @@ export class PaymentClient { transactionHash, orderId: parsed.args.orderId as string, startTime, + duration: parsed.args.duration as bigint, expiresAt: startTime + (parsed.args.duration as bigint), }; } catch { diff --git a/apps/web/src/flow.ts b/apps/web/src/flow.ts index 254a021..32d6157 100644 --- a/apps/web/src/flow.ts +++ b/apps/web/src/flow.ts @@ -14,6 +14,9 @@ export interface RelayJob { sourceTransactionHash: string; phase: RelayPhase; creditcoinTransactionHash?: string; + orderId?: string; + machineId?: string; + payer?: string; accessExpiresAt?: string; failedAtPhase?: RelayPhase; error?: string; diff --git a/apps/web/src/pages/ActivityPage.tsx b/apps/web/src/pages/ActivityPage.tsx index 1ca29c4..157aea0 100644 --- a/apps/web/src/pages/ActivityPage.tsx +++ b/apps/web/src/pages/ActivityPage.tsx @@ -2,110 +2,229 @@ import { useQuery } from '@tanstack/react-query'; import { ArrowRight, Clock3, + Download, ExternalLink, RefreshCw, ShieldCheck, } from 'lucide-react'; -import { Link } from 'react-router'; +import { useEffect, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; import { useRuntime } from '../app/AppProviders.js'; +import { + paginateRentals, + readOptimisticRentals, + type RentalLifecycle, + type RentalRecord, +} from '../activity.js'; import { DataState, PageHeading } from '../components/ProductUI.js'; -import { progressFromJob } from '../flow.js'; -import { compactHash, proofPath, rentalStorageKey } from '../product.js'; -import { ProofWorkerClient } from '../worker.js'; +import { compactHash, machinePath, proofPath, rentPath } from '../product.js'; + +const tabs: Array<{ value: 'all' | RentalLifecycle; label: string }> = [ + { value: 'all', label: 'All activity' }, + { value: 'pending', label: 'Pending' }, + { value: 'active', label: 'Active' }, + { value: 'expired', label: 'Expired' }, + { value: 'failed', label: 'Failed' }, + { value: 'action-required', label: 'Action required' }, +]; export function Component() { - const { config, account } = useRuntime(); - const storageKey = config - ? rentalStorageKey(config.registryAddress, config.machineId) - : ''; - const sourceTransactionHash = storageKey - ? localStorage.getItem(storageKey) - : undefined; - const job = useQuery({ - queryKey: ['relay-job', sourceTransactionHash], - queryFn: () => - new ProofWorkerClient(config!.workerUrl).get(sourceTransactionHash!), - enabled: Boolean(config && sourceTransactionHash), - refetchInterval: 5_000, - retry: false, + const runtime = useRuntime(); + const [params, setParams] = useSearchParams(); + const status = ( + tabs.some((tab) => tab.value === params.get('status')) + ? params.get('status') + : 'all' + ) as 'all' | RentalLifecycle; + const page = Number(params.get('page') ?? 1) || 1; + const optimistic = + runtime.config && runtime.account + ? readOptimisticRentals( + localStorage, + runtime.config.registryAddress, + runtime.account, + ) + : []; + const activity = useQuery({ + queryKey: ['wallet-activity', runtime.account?.toLowerCase()], + queryFn: () => runtime.activityClient!.load(runtime.account!, optimistic), + enabled: Boolean(runtime.activityClient && runtime.account), + retry: 1, + staleTime: 10_000, + refetchInterval: 15_000, }); - const progress = job.data ? progressFromJob(job.data) : undefined; + const result = paginateRentals(activity.data?.records ?? [], status, page, 6); + const counts = Object.fromEntries( + tabs.map((tab) => [ + tab.value, + tab.value === 'all' + ? (activity.data?.records.length ?? 0) + : (activity.data?.records.filter((item) => item.status === tab.value) + .length ?? 0), + ]), + ); + const setTab = (next: string) => { + const query = new URLSearchParams(); + if (next !== 'all') query.set('status', next); + setParams(query, { replace: true }); + }; return (
CONNECTED WALLET - {account ? compactHash(account, 8, 6) : 'Not connected'} + {runtime.account + ? compactHash(runtime.account, 8, 6) + : 'Not connected'}
} /> -
- - - - + +
+ {tabs.map((tab) => ( + + ))}
- {!account ? ( + {!runtime.account ? ( + Connect wallet + + } /> - ) : sourceTransactionHash ? ( -
-
- -
-
- PENDING CROSS-CHAIN RENTAL -

{config?.machineName}

- {compactHash(sourceTransactionHash, 14, 10)} -
-
+ ) : activity.isLoading ? ( +
+ {[0, 1, 2].map((item) => ( +
+ ))} +
+ ) : activity.isError ? ( + void activity.refetch()} + > + Retry all sources + + } + /> + ) : result.items.length ? ( + <> +
+ + Live correlation + + + CC3 time{' '} + {new Date( + (activity.data?.creditcoinTimestamp ?? 0) * 1000, + ).toLocaleString()} + - {job.isFetching && }{' '} - {progress?.label ?? - (job.isError - ? 'Relay temporarily unreachable' - : 'Loading relay state')} + Updated{' '} + {activity.data && + new Date(activity.data.indexedAt).toLocaleTimeString()} - {progress?.verified ? 'VERIFIED' : 'IN PROGRESS'}
-
- - Resume - - - - +
+ {result.items.map((record) => ( + + ))}
-
+ {result.totalPages > 1 && ( + + )} + ) : ( - Find a machine - + status === 'all' ? ( + + Find a machine + + ) : ( + + ) } /> )} @@ -114,15 +233,15 @@ export function Component() {
- History is independently verifiable + Four-source verification

- Every completed row links to both the source payment and - Creditcoin authorization transaction. + Every state is correlated across UsagePaid, the relay index, + ProofKeyASC, and AccessPass.

@@ -132,3 +251,148 @@ export function Component() {
); } + +function RentalRow({ + record, + config, + machineName, +}: { + record: RentalRecord; + config: NonNullable['config']>; + machineName?: string; +}) { + return ( +
+
+ +
+
+ + {record.optimistic ? 'LOCAL PENDING RECEIPT' : 'ON-CHAIN RENTAL'} + +

+ {machineName ?? `Machine ${compactHash(record.machineId, 8, 5)}`} +

+ + {compactHash(record.machineId, 12, 8)} + +
+
+ + {record.status.replace('-', ' ')} + + {record.stateLabel} + {record.nextAction} + {record.status === 'active' && record.expiresAt && ( + + )} + {record.diagnostic && {record.diagnostic}} +
+
+ + Sepolia + + + Proof + + {record.creditcoinTransactionHash && ( + + Creditcoin + + )} +
+
+ {record.status === 'active' ? ( + + Open access + + ) : record.status === 'expired' ? ( + + Rent again + + ) : ( + + {record.status === 'pending' ? 'Track' : 'Resume'}{' '} + + + )} + +
+
+ ); +} + +function StatusIcon({ status }: { status: RentalLifecycle }) { + return status === 'active' ? ( + + ) : status === 'pending' ? ( + + ) : ( + + ); +} + +function Countdown({ expiresAt }: { expiresAt: number }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + const remaining = Math.max(0, expiresAt * 1000 - now); + const hours = Math.floor(remaining / 3_600_000); + const minutes = Math.floor((remaining % 3_600_000) / 60_000); + const seconds = Math.floor((remaining % 60_000) / 1000); + return ( + + ); +} + +function downloadReceipt(record: RentalRecord) { + const payload = JSON.stringify( + { + schema: 'proofkey.rental-receipt.v1', + exportedAt: new Date().toISOString(), + rental: record, + }, + null, + 2, + ); + const url = URL.createObjectURL( + new Blob([payload], { type: 'application/json' }), + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `proofkey-${record.orderId ?? record.sourceTransactionHash}.json`; + anchor.click(); + URL.revokeObjectURL(url); +} + +function updatePage( + params: URLSearchParams, + setParams: ReturnType[1], + page: number, +) { + const next = new URLSearchParams(params); + next.set('page', String(page)); + setParams(next, { replace: true }); +} diff --git a/apps/web/src/pages/RentPage.tsx b/apps/web/src/pages/RentPage.tsx index 195140a..3ad41e7 100644 --- a/apps/web/src/pages/RentPage.tsx +++ b/apps/web/src/pages/RentPage.tsx @@ -9,7 +9,7 @@ import { } from 'lucide-react'; import { formatEther, formatUnits } from 'ethers'; import { useEffect, useMemo, useState } from 'react'; -import { Link, useParams } from 'react-router'; +import { Link, useParams, useSearchParams } from 'react-router'; import { useMarketplace, useRuntime } from '../app/AppProviders.js'; import { DataState, MachineArtwork } from '../components/ProductUI.js'; import type { CheckoutSnapshot, MachineOffer } from '../contracts.js'; @@ -19,7 +19,12 @@ import { totalForDuration, type RelayJob, } from '../flow.js'; -import { describeError, proofPath, rentalStorageKey } from '../product.js'; +import { + describeError, + isTransactionHash, + proofPath, + rentalStorageKey, +} from '../product.js'; import { createRentalSession, MAX_DURATION_SECONDS, @@ -41,6 +46,8 @@ const checkoutSteps = [ export function Component() { const { machineId = '' } = useParams(); + const [searchParams] = useSearchParams(); + const resumeHash = searchParams.get('resume'); const runtime = useRuntime(); const marketplace = useMarketplace(); const machine = marketplace.data?.machines.find( @@ -78,6 +85,24 @@ export function Component() { setJob(undefined); }, [machineId, storageKey]); + useEffect(() => { + if (!resumeHash || !isTransactionHash(resumeHash)) return; + setSession((current) => { + if ( + current.sourceTransactionHash?.toLowerCase() === + resumeHash.toLowerCase() + ) + return current; + const next = updateRentalSession(createRentalSession(machineId), { + phase: 'confirming', + account: runtime.account, + sourceTransactionHash: resumeHash, + }); + localStorage.setItem(storageKey, JSON.stringify(next)); + return next; + }); + }, [machineId, resumeHash, runtime.account, storageKey]); + const commit = ( update: Partial>, ) => { @@ -283,6 +308,7 @@ export function Component() { commit({ phase: 'confirming', orderId: recovered.orderId, + durationSeconds: Number(recovered.duration), startTime: recovered.startTime.toString(), expiresAt: recovered.expiresAt.toString(), }); @@ -322,6 +348,7 @@ export function Component() { phase: 'confirming', sourceTransactionHash: payment.transactionHash, orderId: payment.orderId, + durationSeconds: Number(payment.duration), startTime: payment.startTime.toString(), expiresAt: payment.expiresAt.toString(), }); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 96b3915..fcabae9 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1582,6 +1582,166 @@ input:focus-visible { border-radius: 18px; background: var(--paper); } +.activity-tabs { + overflow-x: auto; +} +.activity-tabs button { + display: inline-flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} +.activity-tabs button > span { + display: grid; + min-width: 19px; + height: 19px; + place-items: center; + border-radius: 999px; + background: rgba(18, 27, 22, 0.08); + font-family: 'DM Mono', monospace; + font-size: 7px; +} +.activity-tabs button.active > span { + background: rgba(255, 255, 255, 0.16); +} +.activity-list { + display: grid; + gap: 10px; +} +.activity-skeleton { + min-height: 155px; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0.35) 20%, + rgba(255, 255, 255, 0.85) 40%, + rgba(255, 255, 255, 0.35) 60% + ); + background-size: 220% 100%; + animation: marketplace-shimmer 1.3s infinite linear; +} +.activity-index-state { + display: flex; + justify-content: flex-end; + gap: 15px; + margin: -7px 0 12px; + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 8px; + text-transform: uppercase; +} +.activity-index-state span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.rental-row { + display: grid; + grid-template-columns: auto minmax(160px, 0.8fr) minmax( + 230px, + 1.25fr + ) auto auto; + align-items: center; + gap: 18px; + padding: 20px; + border: 1px solid var(--line); + border-left: 4px solid #a7afa9; + border-radius: 18px; + background: var(--paper); + box-shadow: 0 8px 28px rgba(17, 32, 24, 0.04); +} +.rental-row.active { + border-left-color: #36a369; +} +.rental-row.pending { + border-left-color: #d69c38; +} +.rental-row.failed, +.rental-row.action-required { + border-left-color: var(--orange); +} +.rental-row .activity-main > a { + color: var(--muted); + font-family: 'DM Mono', monospace; + font-size: 8px; + text-decoration: none; +} +.rental-lifecycle { + display: grid; + gap: 5px; +} +.rental-lifecycle > strong { + font-size: 11px; +} +.rental-lifecycle > small { + color: var(--muted); + font-size: 9px; +} +.rental-lifecycle > time { + color: #28734e; + font-family: 'DM Mono', monospace; + font-size: 9px; +} +.rental-lifecycle > em { + margin-top: 3px; + padding: 7px; + border-radius: 7px; + background: rgba(255, 104, 71, 0.09); + color: #98442e; + font-size: 8px; + font-style: normal; + line-height: 1.4; +} +.rental-status { + width: fit-content; + padding: 4px 7px; + border-radius: 999px; + background: #e5e8e1; + color: #687169; + font-family: 'DM Mono', monospace; + font-size: 7px; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.rental-status.active { + background: #dff4e5; + color: #28734e; +} +.rental-status.pending { + background: #fff0cf; + color: #94691e; +} +.rental-status.failed, +.rental-status.action-required { + background: #ffe4dc; + color: #98442e; +} +.rental-evidence { + display: grid; + gap: 5px; +} +.rental-evidence a { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: var(--forest); + font-size: 8px; + font-weight: 800; + text-decoration: none; +} +.receipt-download { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid var(--line); + border-radius: 11px; + background: transparent; + color: var(--forest); + cursor: pointer; +} .activity-icon { display: grid; width: 48px; @@ -2592,6 +2752,19 @@ input:focus-visible { .activity-card { grid-template-columns: auto 1fr; } + .rental-row { + grid-template-columns: auto 1fr; + } + .rental-lifecycle, + .rental-evidence, + .rental-row .activity-actions { + grid-column: 2; + } + .activity-index-state { + align-items: flex-end; + flex-direction: column; + gap: 5px; + } .activity-state, .activity-actions { grid-column: 2; @@ -2683,6 +2856,16 @@ input:focus-visible { align-items: flex-start; flex-direction: column; } + .rental-row { + display: flex; + align-items: flex-start; + flex-direction: column; + } + .rental-lifecycle, + .rental-evidence, + .rental-row .activity-actions { + width: 100%; + } .activity-state, .activity-actions { width: 100%; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 7bc1aa4..ec7323a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -32,4 +32,7 @@ interface ImportMetaEnv { readonly VITE_DEMO_MACHINE_LOCATION?: string; readonly VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK?: string; readonly VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK?: string; + readonly VITE_ACCESS_PASS_ADDRESS?: string; + readonly VITE_PROOFKEY_ASC_ADDRESS?: string; + readonly VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK?: string; } diff --git a/apps/web/src/worker.ts b/apps/web/src/worker.ts index 0a5d4d3..6bf6106 100644 --- a/apps/web/src/worker.ts +++ b/apps/web/src/worker.ts @@ -28,6 +28,12 @@ export class ProofWorkerClient { return this.parse(response); } + async find(transactionHash: string): Promise { + const response = await fetch(`${this.baseUrl}/jobs/${transactionHash}`); + if (response.status === 404) return undefined; + return this.parse(response); + } + async enqueue(transactionHash: string): Promise { const response = await fetch(`${this.baseUrl}/jobs`, { method: 'POST', diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json index 7a140d7..13027dc 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/vite-env.d.ts" + "src/vite-env.d.ts", + "src/activity.ts", + "src/activity.test.ts" ] } From 58263f2165e903dc06f1579a552c1243b1b4d24c Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Sun, 13 Sep 2026 13:08:30 +0100 Subject: [PATCH 2/2] style: format activity dashboard styles --- apps/web/src/styles.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fcabae9..8716ce1 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1638,10 +1638,9 @@ input:focus-visible { } .rental-row { display: grid; - grid-template-columns: auto minmax(160px, 0.8fr) minmax( - 230px, - 1.25fr - ) auto auto; + grid-template-columns: + auto minmax(160px, 0.8fr) minmax(230px, 1.25fr) + auto auto; align-items: center; gap: 18px; padding: 20px;