diff --git a/.agent/context/20260907T190600Z-a05-frontend-intent-status.md b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md
new file mode 100644
index 0000000..15731b1
--- /dev/null
+++ b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md
@@ -0,0 +1,73 @@
+# Session Context: A05 Frontend Intent and Authoritative Status
+
+## Date/time
+
+- Started: 2026-09-07T19:06:00Z
+- Continued: 2026-09-08T12:29:20Z
+
+## User goal and original request
+
+Implement Coder A milestone A05 after Gate P4: "Делай" in response to the identified next packet, A05 Frontend Intent and Authoritative Status.
+
+## Assumptions and non-goals
+
+- Base is current `origin/develop` at `1250dec`.
+- A05 consumes frozen OpenAPI v1 and does not add settlement capability.
+- The browser receives a demo service token only at runtime and does not persist it.
+- B05/C05 composition remains project Gate P5 work.
+- No production API deployment, mainnet action, settlement detail UI, or visual polish campaign is included.
+
+## Plan and decisions
+
+- Preserve and finish the existing uncommitted A05 implementation after moving its base from A04 to current `develop`.
+- Use generated `@oneshot/contracts` types and a small typed fetch client.
+- Keep exact USDC parsing and formatting string/`bigint` based.
+- Preserve the same Business Intent ID for replay; generate another ID only through an explicit new-obligation action.
+- Stop polling at `UNKNOWN`; expose reconciliation only, never payment retry.
+- Replace the Cloudflare placeholder asset with the built A05 SPA.
+- Use React/Vite dependencies already present in the workspace; add no design system or state library.
+
+## Files and components
+
+- `apps/web`: application shell, API client, intent form, status view, readiness banner, exact money helpers, tests, and setup documentation.
+- Root TypeScript/ESLint/workspace lock configuration includes the new app.
+- Root Wrangler assets now point to `apps/web/dist`; the old placeholder is removed.
+- Root README lists the operator frontend and its local command.
+
+## Commands and checks
+
+- `pnpm --filter @oneshot/web test`: 27 tests passed.
+- `pnpm --filter @oneshot/web lint`: passed.
+- `pnpm --filter @oneshot/web typecheck`: passed.
+- `pnpm --filter @oneshot/web build`: passed; production source maps remain disabled.
+- `pnpm exec wrangler deploy --dry-run`: passed with `apps/web/dist` assets.
+- `pnpm format:check`, root `pnpm lint`, root `pnpm typecheck`, generated-contract check, and fixture validation: passed.
+- Root `pnpm test`: 576 tests passed across 43 files.
+- `TEST_POSTGRES=1 pnpm test:integration`: unavailable locally because no container runtime is running; the first Testcontainers suite reported `Could not find a working container runtime strategy`. Required CI provides the integration environment.
+- Local runtime uses Node 22.23.2 and reports the repository's expected Node 24.19.0 engine warning; CI uses `.nvmrc`.
+
+## External documentation findings
+
+- Wrangler 4.127.0 local schema accepts `assets.not_found_handling = "single-page-application"`.
+- Added `build.command = "pnpm --filter @oneshot/web build"` to `wrangler.jsonc` so Cloudflare Workers Builds automatically builds the Vite SPA before asset upload.
+
+## Unresolved questions
+
+- None for A05 packet scope. Production API deployment and P5 UI composition remain later work.
+
+## Branch, commit, PR, and review state
+
+- Branch: `milestone/a05-frontend-intent-status`
+- Base: `origin/develop` at `1250dec79bc702939fe2a3b0fd00e66bb34128af`
+- Initial commit: `d3fbfacdee365d159807256a53011140442867ff`
+- PR: [#32](https://github.com/SWOFART/OneShot/pull/32)
+- Gate A: IN PROGRESS (free-pi-cli glm-5.3-flash)
+- Gate B: pending CI and exact-head review
+
+## Handoff and next steps
+
+- Validate wrangler build integration locally.
+- Re-run Gate A, commit fix, push to PR #32.
+- Verify CI passes (including Workers Builds).
+- Run Gate B and mark PR ready for review.
+- Project Gate P5 will compose B05/C05 UI packages after A05 closes.
diff --git a/README.md b/README.md
index 5f8c746..2d466a5 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,7 @@ lock: OneShot's durable state is.
```text
apps/api HTTP seam
+apps/web operator intent and status UI
apps/worker settlement and reconciliation workers
packages/contracts frozen v1 contract pack, OpenAPI, fixtures
packages/domain intent, attempt, and settlement state
@@ -143,6 +144,7 @@ Requires Node `24.19.0`, pnpm `11.19.0`, and PostgreSQL for integration tests.
pnpm install
pnpm lint && pnpm typecheck && pnpm build
pnpm test
+pnpm dev:frontend
```
Integration tests need a database:
@@ -188,7 +190,7 @@ Under active development. **Testnet only.**
| Settlement adapters and error taxonomy | Implemented, exercised against simulators |
| Recovery evidence and safety core | In progress |
| Subgraph MCP discovery and LLM recovery agent | Planned |
-| Operator frontend | Not started |
+| Operator frontend | Intent creation, replay/conflict, and authoritative status implemented |
**No live settlement has been executed.** No Privy application, wallet, policy,
or funded testnet account has been provisioned for this build. The adapters are
diff --git a/apps/placeholder-frontend/index.html b/apps/placeholder-frontend/index.html
deleted file mode 100644
index e31c964..0000000
--- a/apps/placeholder-frontend/index.html
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
- OneShot — reliable agent payments
-
-
-
-
- Building on Arc Testnet
- OneShot
-
- Reliable payments for autonomous agents.
- One intent, at most one settlement.
-
-
-
-
-
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..92999af
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1 @@
+VITE_ONESHOT_API_BASE_URL=http://127.0.0.1:3001
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000..c91d7d5
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,11 @@
+# OneShot web
+
+Minimal operator UI for creating or replaying a Business Intent and reading its authoritative status.
+
+```powershell
+pnpm --filter @oneshot/web dev
+```
+
+Vite proxies `/v1` and `/health` to the local API. For a separate deployed API, set the public build variable `VITE_ONESHOT_API_BASE_URL`. Enter the demo service token at runtime; the UI keeps it in memory and never persists it.
+
+The client consumes generated `@oneshot/contracts` types from frozen OpenAPI v1. Tests use deterministic fetch responses matching that contract.
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..d855379
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ OneShot — Intent & Authoritative Status
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..f8d4ab1
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@oneshot/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc -b && vite build",
+ "clean": "tsc -b --clean",
+ "dev": "vite",
+ "lint": "eslint src test",
+ "preview": "vite preview",
+ "test": "vitest run --config vitest.config.ts",
+ "typecheck": "tsc -b --pretty false"
+ },
+ "dependencies": {
+ "@oneshot/contracts": "workspace:*",
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@testing-library/react": "16.3.3",
+ "@testing-library/user-event": "14.6.7",
+ "@types/node": "24.13.3",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
+ "@vitejs/plugin-react": "6.1.1",
+ "axe-core": "4.13.0",
+ "jsdom": "30.0.1",
+ "typescript": "6.0.3",
+ "vite": "8.0.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..ce425ab
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,83 @@
+import { useMemo, useState } from 'react';
+
+import { OneShotApiClient } from './api/client.js';
+import { ErrorBoundary } from './components/ErrorBoundary.js';
+import { IntentForm } from './components/IntentForm.js';
+import { IntentStatusView } from './components/IntentStatusView.js';
+import { ReadinessBanner } from './components/ReadinessBanner.js';
+import './styles.css';
+
+type Tab = 'create' | 'status';
+
+export function App() {
+ const [activeTab, setActiveTab] = useState('create');
+ const [selectedIntentId, setSelectedIntentId] = useState('');
+ const [authToken, setAuthToken] = useState('');
+ const client = useMemo(
+ () =>
+ new OneShotApiClient({
+ baseUrl: import.meta.env.VITE_ONESHOT_API_BASE_URL ?? '',
+ getAuthToken: () => authToken.trim() || null,
+ }),
+ [authToken],
+ );
+
+ function showStatus(intentId: string): void {
+ setSelectedIntentId(intentId);
+ setActiveTab('status');
+ }
+
+ return (
+
+
+
+
+
+
+
+ setActiveTab('create')}
+ >
+ Create or replay
+
+ setActiveTab('status')}
+ >
+ Authoritative status
+
+
+
+
+ {activeTab === 'create' ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
new file mode 100644
index 0000000..45bab29
--- /dev/null
+++ b/apps/web/src/api/client.ts
@@ -0,0 +1,241 @@
+import type {
+ CreateIntentRequest,
+ ErrorResponse,
+ IntentResponse,
+ ReconcileResponse,
+} from '@oneshot/contracts';
+
+export interface ApiClientConfig {
+ readonly baseUrl?: string;
+ readonly getAuthToken?: () => string | null;
+ readonly fetchFn?: typeof fetch;
+}
+
+export type CreateIntentResult =
+ | { readonly kind: 'ACCEPTED'; readonly intent: IntentResponse; readonly correlationId: string }
+ | { readonly kind: 'REPLAYED'; readonly intent: IntentResponse; readonly correlationId: string }
+ | { readonly kind: 'PAYLOAD_CONFLICT'; readonly message: string; readonly correlationId: string }
+ | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string }
+ | { readonly kind: 'RATE_LIMITED'; readonly message: string; readonly correlationId: string }
+ | { readonly kind: 'NOT_READY'; readonly message: string; readonly correlationId: string }
+ | {
+ readonly kind: 'ERROR';
+ readonly code: string;
+ readonly message: string;
+ readonly correlationId: string;
+ };
+
+export type GetIntentResult =
+ | { readonly kind: 'SUCCESS'; readonly intent: IntentResponse; readonly correlationId: string }
+ | { readonly kind: 'NOT_FOUND'; readonly correlationId: string }
+ | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string }
+ | {
+ readonly kind: 'ERROR';
+ readonly code: string;
+ readonly message: string;
+ readonly correlationId: string;
+ };
+
+export type ReconcileResult =
+ | {
+ readonly kind: 'QUEUED';
+ readonly response: ReconcileResponse;
+ readonly correlationId: string;
+ }
+ | { readonly kind: 'NOT_ALLOWED'; readonly message: string; readonly correlationId: string }
+ | { readonly kind: 'NOT_FOUND'; readonly correlationId: string }
+ | { readonly kind: 'ERROR'; readonly message: string; readonly correlationId: string };
+
+export type ReadinessResult =
+ | { readonly status: 'ok'; readonly submissions_disabled?: boolean }
+ | { readonly status: 'not_ready'; readonly message: string };
+
+export class OneShotApiClient {
+ private readonly baseUrl: string;
+ private readonly getAuthToken: () => string | null;
+ private readonly fetchFn: typeof fetch;
+
+ constructor(config?: ApiClientConfig) {
+ this.baseUrl = config?.baseUrl ?? '';
+ this.getAuthToken = config?.getAuthToken ?? (() => null);
+ this.fetchFn = config?.fetchFn ?? fetch.bind(globalThis);
+ }
+
+ private buildHeaders(customCorrelationId?: string): HeadersInit {
+ const headers: Record = {
+ 'content-type': 'application/json',
+ 'x-correlation-id': customCorrelationId ?? crypto.randomUUID(),
+ };
+ const token = this.getAuthToken();
+ if (token) {
+ headers.authorization = `Bearer ${token}`;
+ }
+ return headers;
+ }
+
+ async createOrReplayIntent(
+ request: CreateIntentRequest,
+ correlationId?: string,
+ ): Promise {
+ const headers = this.buildHeaders(correlationId);
+ const corrId = (headers as Record)['x-correlation-id'] ?? '';
+
+ try {
+ const res = await this.fetchFn(`${this.baseUrl}/v1/intents`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(request),
+ });
+
+ if (res.status === 202) {
+ const intent = (await res.json()) as IntentResponse;
+ return { kind: 'ACCEPTED', intent, correlationId: corrId };
+ }
+
+ if (res.status === 200) {
+ const intent = (await res.json()) as IntentResponse;
+ return { kind: 'REPLAYED', intent, correlationId: corrId };
+ }
+
+ const err = (await res.json().catch(() => ({}))) as Partial;
+ const message = err.message ?? 'Unknown error';
+
+ if (res.status === 409 && err.code === 'INTENT_PAYLOAD_CONFLICT') {
+ return { kind: 'PAYLOAD_CONFLICT', message, correlationId: corrId };
+ }
+
+ if (res.status === 401 || res.status === 403) {
+ return { kind: 'UNAUTHORIZED', message, correlationId: corrId };
+ }
+
+ if (res.status === 429) {
+ return { kind: 'RATE_LIMITED', message, correlationId: corrId };
+ }
+
+ if (res.status === 503) {
+ return { kind: 'NOT_READY', message, correlationId: corrId };
+ }
+
+ return {
+ kind: 'ERROR',
+ code: err.code ?? 'UNKNOWN_ERROR',
+ message,
+ correlationId: corrId,
+ };
+ } catch (e) {
+ return {
+ kind: 'ERROR',
+ code: 'NETWORK_ERROR',
+ message: e instanceof Error ? e.message : 'Network request failed',
+ correlationId: corrId,
+ };
+ }
+ }
+
+ async getIntent(id: string, correlationId?: string): Promise {
+ const headers = this.buildHeaders(correlationId);
+ const corrId = (headers as Record)['x-correlation-id'] ?? '';
+
+ try {
+ const res = await this.fetchFn(`${this.baseUrl}/v1/intents/${encodeURIComponent(id)}`, {
+ method: 'GET',
+ headers,
+ });
+
+ if (res.status === 200) {
+ const intent = (await res.json()) as IntentResponse;
+ return { kind: 'SUCCESS', intent, correlationId: corrId };
+ }
+
+ if (res.status === 404) {
+ return { kind: 'NOT_FOUND', correlationId: corrId };
+ }
+
+ const err = (await res.json().catch(() => ({}))) as Partial;
+ const message = err.message ?? 'Unknown error';
+
+ if (res.status === 401 || res.status === 403) {
+ return { kind: 'UNAUTHORIZED', message, correlationId: corrId };
+ }
+
+ return {
+ kind: 'ERROR',
+ code: err.code ?? 'UNKNOWN_ERROR',
+ message,
+ correlationId: corrId,
+ };
+ } catch (e) {
+ return {
+ kind: 'ERROR',
+ code: 'NETWORK_ERROR',
+ message: e instanceof Error ? e.message : 'Network request failed',
+ correlationId: corrId,
+ };
+ }
+ }
+
+ async reconcileIntent(id: string, correlationId?: string): Promise {
+ const headers = this.buildHeaders(correlationId);
+ const corrId = (headers as Record)['x-correlation-id'] ?? '';
+
+ try {
+ const res = await this.fetchFn(
+ `${this.baseUrl}/v1/intents/${encodeURIComponent(id)}/reconcile`,
+ {
+ method: 'POST',
+ headers,
+ },
+ );
+
+ if (res.status === 202) {
+ const response = (await res.json()) as ReconcileResponse;
+ return { kind: 'QUEUED', response, correlationId: corrId };
+ }
+
+ if (res.status === 404) {
+ return { kind: 'NOT_FOUND', correlationId: corrId };
+ }
+
+ const err = (await res.json().catch(() => ({}))) as Partial;
+ const message = err.message ?? 'Reconciliation not allowed';
+
+ if (res.status === 409) {
+ return { kind: 'NOT_ALLOWED', message, correlationId: corrId };
+ }
+
+ return { kind: 'ERROR', message, correlationId: corrId };
+ } catch (e) {
+ return {
+ kind: 'ERROR',
+ message: e instanceof Error ? e.message : 'Network error',
+ correlationId: corrId,
+ };
+ }
+ }
+
+ async getReadiness(): Promise {
+ try {
+ const res = await this.fetchFn(`${this.baseUrl}/health/ready`, {
+ method: 'GET',
+ });
+
+ if (res.status === 200) {
+ const body = (await res.json()) as { status: 'ok'; submissions_disabled?: boolean };
+ return {
+ status: 'ok',
+ ...(body.submissions_disabled !== undefined
+ ? { submissions_disabled: body.submissions_disabled }
+ : {}),
+ };
+ }
+
+ const err = (await res.json().catch(() => ({}))) as Partial;
+ return { status: 'not_ready', message: err.message ?? 'Service not ready' };
+ } catch (e) {
+ return {
+ status: 'not_ready',
+ message: e instanceof Error ? e.message : 'Readiness check failed',
+ };
+ }
+ }
+}
diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..255eb33
--- /dev/null
+++ b/apps/web/src/components/ErrorBoundary.tsx
@@ -0,0 +1,36 @@
+import { Component, type ReactNode } from 'react';
+
+interface Props {
+ readonly children: ReactNode;
+}
+
+interface State {
+ readonly error: string | null;
+}
+
+export class ErrorBoundary extends Component {
+ override state: State = { error: null };
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error: error.message || 'Unexpected application error.' };
+ }
+
+ override componentDidCatch(error: Error): void {
+ console.error('UI boundary caught error:', error.name);
+ }
+
+ override render(): ReactNode {
+ if (this.state.error === null) return this.props.children;
+ return (
+
+
+ Something went wrong
+ {this.state.error}
+ this.setState({ error: null })}>
+ Try again
+
+
+
+ );
+ }
+}
diff --git a/apps/web/src/components/IntentForm.tsx b/apps/web/src/components/IntentForm.tsx
new file mode 100644
index 0000000..eb98a25
--- /dev/null
+++ b/apps/web/src/components/IntentForm.tsx
@@ -0,0 +1,189 @@
+import { parseCreateIntentRequest, type CreateIntentRequest } from '@oneshot/contracts';
+import { useMemo, useState, type FormEvent } from 'react';
+
+import type { CreateIntentResult, OneShotApiClient } from '../api/client.js';
+import { usdcToAtomicUnits } from '../utils/money.js';
+
+interface Props {
+ readonly client: OneShotApiClient;
+ readonly onIntentCreatedOrSelected?: (intentId: string) => void;
+}
+
+interface Outcome {
+ readonly kind: 'accepted' | 'replayed' | 'conflict' | 'error';
+ readonly title: string;
+ readonly message: string;
+}
+
+function buildRequest(
+ intentId: string,
+ recipient: string,
+ humanAmount: string,
+ purpose: string,
+): CreateIntentRequest {
+ const amount = usdcToAtomicUnits(humanAmount);
+ if (amount === '0') throw new Error('Amount must be greater than zero.');
+ return parseCreateIntentRequest({
+ business_intent_id: intentId.trim(),
+ recipient: recipient.trim(),
+ amount_atomic: amount,
+ asset: 'USDC',
+ network: 'eip155:5042002',
+ purpose: purpose.trim(),
+ });
+}
+
+function outcomeFor(result: CreateIntentResult): Outcome {
+ switch (result.kind) {
+ case 'ACCEPTED':
+ return {
+ kind: 'accepted',
+ title: 'ACCEPTED — new intent',
+ message: `Authoritative state: ${result.intent.state}.`,
+ };
+ case 'REPLAYED':
+ return {
+ kind: 'replayed',
+ title: 'REPLAYED — identical payload',
+ message: 'Existing intent returned. No duplicate settlement was created.',
+ };
+ case 'PAYLOAD_CONFLICT':
+ return {
+ kind: 'conflict',
+ title: 'PAYLOAD CONFLICT',
+ message:
+ 'This ID already belongs to another immutable payload. Use a new ID only for a new obligation.',
+ };
+ default:
+ return { kind: 'error', title: 'REQUEST FAILED', message: result.message };
+ }
+}
+
+export function IntentForm({ client, onIntentCreatedOrSelected }: Props) {
+ const [intentId, setIntentId] = useState(() => crypto.randomUUID());
+ const [recipient, setRecipient] = useState('');
+ const [amount, setAmount] = useState('1.00');
+ const [purpose, setPurpose] = useState('Paid API job');
+ const [submitting, setSubmitting] = useState(false);
+ const [validationError, setValidationError] = useState(null);
+ const [outcome, setOutcome] = useState(null);
+ const atomicPreview = useMemo(() => {
+ try {
+ return usdcToAtomicUnits(amount);
+ } catch {
+ return 'Invalid amount';
+ }
+ }, [amount]);
+
+ function newObligation(): void {
+ setIntentId(crypto.randomUUID());
+ setOutcome(null);
+ setValidationError(null);
+ }
+
+ async function submit(event: FormEvent): Promise {
+ event.preventDefault();
+ setOutcome(null);
+ setValidationError(null);
+ let request: CreateIntentRequest;
+ try {
+ request = buildRequest(intentId, recipient, amount, purpose);
+ } catch (error) {
+ setValidationError(error instanceof Error ? error.message : 'Invalid intent.');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const result = await client.createOrReplayIntent(request);
+ setOutcome(outcomeFor(result));
+ if (result.kind === 'ACCEPTED' || result.kind === 'REPLAYED') {
+ onIntentCreatedOrSelected?.(result.intent.business_intent_id);
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/IntentStatusView.tsx b/apps/web/src/components/IntentStatusView.tsx
new file mode 100644
index 0000000..401c521
--- /dev/null
+++ b/apps/web/src/components/IntentStatusView.tsx
@@ -0,0 +1,225 @@
+import type { IntentResponse, IntentState } from '@oneshot/contracts';
+import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
+
+import type { OneShotApiClient } from '../api/client.js';
+import { atomicUnitsToUsdc } from '../utils/money.js';
+
+interface Props {
+ readonly client: OneShotApiClient;
+ readonly initialIntentId?: string;
+}
+
+const STOP_POLLING = new Set(['COMMITTED', 'FAILED_SAFE', 'REJECTED', 'UNKNOWN']);
+const MAX_POLLS = 12;
+
+export function IntentStatusView({ client, initialIntentId = '' }: Props) {
+ const [searchId, setSearchId] = useState(initialIntentId);
+ const [activeId, setActiveId] = useState(initialIntentId);
+ const [intent, setIntent] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [pollCount, setPollCount] = useState(0);
+ const [polling, setPolling] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [reconcileMessage, setReconcileMessage] = useState(null);
+ const [reconciling, setReconciling] = useState(false);
+ const timer = useRef | null>(null);
+
+ useEffect(() => {
+ if (initialIntentId && initialIntentId !== activeId) {
+ setSearchId(initialIntentId);
+ setActiveId(initialIntentId);
+ }
+ }, [activeId, initialIntentId]);
+
+ const read = useCallback(
+ async (id: string, showLoading = true): Promise => {
+ if (showLoading) setLoading(true);
+ setMessage(null);
+ try {
+ const result = await client.getIntent(id);
+ if (result.kind === 'SUCCESS') {
+ setIntent(result.intent);
+ return result.intent;
+ }
+ setIntent(null);
+ setMessage(result.kind === 'NOT_FOUND' ? `Intent "${id}" was not found.` : result.message);
+ return null;
+ } finally {
+ if (showLoading) setLoading(false);
+ }
+ },
+ [client],
+ );
+
+ useEffect(() => {
+ if (!activeId) return;
+ let cancelled = false;
+ setPollCount(0);
+ setPolling(true);
+
+ async function poll(attempt: number): Promise {
+ const current = await read(activeId, attempt === 0);
+ if (
+ cancelled ||
+ current === null ||
+ STOP_POLLING.has(current.state) ||
+ attempt + 1 >= MAX_POLLS
+ ) {
+ setPolling(false);
+ return;
+ }
+ setPollCount(attempt + 1);
+ timer.current = setTimeout(
+ () => void poll(attempt + 1),
+ Math.min(1_000 + attempt * 500, 4_000),
+ );
+ }
+
+ void poll(0);
+ return () => {
+ cancelled = true;
+ if (timer.current) clearTimeout(timer.current);
+ };
+ }, [activeId, read]);
+
+ function lookup(event: FormEvent): void {
+ event.preventDefault();
+ const id = searchId.trim();
+ if (!id) return;
+ if (id === activeId) void read(id);
+ else setActiveId(id);
+ }
+
+ async function reconcile(): Promise {
+ if (intent?.state !== 'UNKNOWN') return;
+ setReconciling(true);
+ setReconcileMessage(null);
+ try {
+ const result = await client.reconcileIntent(intent.business_intent_id);
+ if (result.kind === 'QUEUED') {
+ setReconcileMessage(
+ 'Reconciliation job enqueued. Settlement remains blocked pending evidence.',
+ );
+ await read(intent.business_intent_id);
+ } else if (result.kind === 'NOT_FOUND') {
+ setReconcileMessage('Reconciliation failed: intent was not found.');
+ } else {
+ setReconcileMessage(`Reconciliation failed: ${result.message}`);
+ }
+ } finally {
+ setReconciling(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+ {polling && (
+
+ Polling authoritative state · {pollCount + 1}/{MAX_POLLS}
+
+ )}
+
+ {intent && (
+
+
+
+ Authoritative state
+ {intent.state}
+
+
+ Ledger version
+ {intent.version}
+
+
+ Attempts
+ {intent.attempts.length}
+
+
+
+ {intent.state === 'UNKNOWN' && (
+
+ Settlement outcome is unknown
+ No retry is allowed until reconciliation finds authoritative evidence.
+ void reconcile()}>
+ {reconciling ? 'Enqueueing…' : 'Enqueue Reconciliation'}
+
+ {reconcileMessage && {reconcileMessage}
}
+
+ )}
+
+
+
+
Business Intent ID
+ {intent.business_intent_id}
+
+
+
Recipient
+ {intent.recipient}
+
+
+
Amount
+
+ {atomicUnitsToUsdc(intent.amount_atomic)} USDC{' '}
+ ({intent.amount_atomic} atomic)
+
+
+
+
Network
+ {intent.network}
+
+
+
Purpose
+ {intent.purpose}
+
+
+
+
+ Attempts
+ {intent.attempts.length === 0 ? (
+ No execution attempts yet.
+ ) : (
+
+ {intent.attempts.map((attempt) => (
+
+ {attempt.stage} · {attempt.created_at}
+ {attempt.sanitized_error && {attempt.sanitized_error}
}
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/ReadinessBanner.tsx b/apps/web/src/components/ReadinessBanner.tsx
new file mode 100644
index 0000000..261d309
--- /dev/null
+++ b/apps/web/src/components/ReadinessBanner.tsx
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+import type { OneShotApiClient, ReadinessResult } from '../api/client.js';
+
+export function ReadinessBanner({ client }: { readonly client: OneShotApiClient }) {
+ const [readiness, setReadiness] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ void client.getReadiness().then((result) => {
+ if (active) setReadiness(result);
+ });
+ return () => {
+ active = false;
+ };
+ }, [client]);
+
+ if (readiness === null) return Checking backend readiness…
;
+ if (readiness.status !== 'ok') {
+ return Backend unavailable: {readiness.message}
;
+ }
+ if (readiness.submissions_disabled) {
+ return (
+
+ Safe mode: submissions paused; reads and recovery remain available.
+
+ );
+ }
+ return Backend ready · Arc Testnet · USDC
;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..28d3d29
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App.js';
+
+const container = document.getElementById('root');
+if (!container) {
+ throw new Error('Root container #root not found in document');
+}
+
+const root = createRoot(container);
+root.render(
+
+
+ ,
+);
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
new file mode 100644
index 0000000..6e75ae2
--- /dev/null
+++ b/apps/web/src/styles.css
@@ -0,0 +1,350 @@
+:root {
+ color: #172033;
+ background: #f3f6fb;
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ min-height: 42px;
+ padding: 0.65rem 1rem;
+ border: 0;
+ border-radius: 0.65rem;
+ color: white;
+ background: #2056c9;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+button.secondary {
+ color: #172033;
+ background: #e8edf7;
+}
+
+button.compact {
+ min-height: 32px;
+ padding: 0.35rem 0.65rem;
+ font-size: 0.8rem;
+}
+
+button:focus-visible,
+input:focus-visible {
+ outline: 3px solid #68a0ff;
+ outline-offset: 2px;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+.app-shell {
+ width: min(880px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 2.5rem 0;
+}
+
+.app-header h1 {
+ margin-bottom: 0.5rem;
+ font-size: clamp(1.8rem, 5vw, 3rem);
+ line-height: 1.05;
+}
+
+.app-header > p:not(.eyebrow) {
+ color: #52617a;
+}
+
+.eyebrow {
+ margin-bottom: 0.7rem;
+ color: #2056c9;
+ font-size: 0.75rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+}
+
+.readiness,
+.auth-bar,
+.panel {
+ border: 1px solid #d8dfeb;
+ border-radius: 0.9rem;
+ background: white;
+}
+
+.readiness {
+ padding: 0.75rem 1rem;
+ color: #42516a;
+}
+
+.readiness.success {
+ border-color: #9ed8af;
+ color: #176b35;
+ background: #effaf2;
+}
+
+.readiness.warning {
+ border-color: #e8c774;
+ color: #704d00;
+ background: #fff8df;
+}
+
+.auth-bar {
+ display: grid;
+ grid-template-columns: auto minmax(180px, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 1rem 0;
+ padding: 0.75rem 1rem;
+}
+
+.auth-bar label,
+.field-label,
+.form label {
+ font-weight: 700;
+}
+
+.auth-bar small,
+.form small,
+.muted {
+ color: #5d6b82;
+}
+
+.tabs {
+ display: flex;
+ gap: 0.35rem;
+ margin: 1rem 0;
+ border-bottom: 1px solid #d8dfeb;
+}
+
+.tabs button {
+ border-radius: 0.65rem 0.65rem 0 0;
+ color: #52617a;
+ background: transparent;
+}
+
+.tabs button[aria-selected='true'] {
+ color: #1648ad;
+ background: white;
+}
+
+.panel {
+ padding: clamp(1rem, 4vw, 1.75rem);
+ box-shadow: 0 12px 35px rgb(31 55 90 / 8%);
+}
+
+.panel header p {
+ color: #52617a;
+}
+
+.form {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.form input,
+.lookup input,
+.auth-bar input,
+.form output {
+ width: 100%;
+ min-height: 42px;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid #aeb9ca;
+ border-radius: 0.6rem;
+ color: #172033;
+ background: white;
+}
+
+.form output {
+ display: flex;
+ align-items: center;
+ margin-top: 0.35rem;
+ background: #f6f8fb;
+}
+
+.label-row,
+.lookup,
+.state-card {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.label-row {
+ justify-content: space-between;
+}
+
+.payment-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.payment-grid > div {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.notice {
+ padding: 0.9rem 1rem;
+ border: 1px solid #b9c7da;
+ border-radius: 0.65rem;
+ background: #f5f8fd;
+}
+
+.notice p:last-child {
+ margin-bottom: 0;
+}
+
+.notice.accepted,
+.notice.replayed {
+ border-color: #9ed8af;
+ color: #176b35;
+ background: #effaf2;
+}
+
+.notice.warning,
+.notice.conflict {
+ border-color: #e8c774;
+ color: #704d00;
+ background: #fff8df;
+}
+
+.notice.error {
+ border-color: #e2a2a7;
+ color: #8d1f29;
+ background: #fff1f2;
+}
+
+.lookup {
+ align-items: stretch;
+ margin-bottom: 1rem;
+}
+
+.lookup input {
+ flex: 1;
+}
+
+.polling {
+ color: #1648ad;
+ font-weight: 700;
+}
+
+.intent-details {
+ display: grid;
+ gap: 1rem;
+}
+
+.state-card {
+ justify-content: space-between;
+ padding: 1rem;
+ border: 1px solid #9bb9ec;
+ border-radius: 0.75rem;
+ background: #edf4ff;
+}
+
+.state-card div {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.state-card small {
+ color: #52617a;
+}
+
+.state-card strong {
+ font-size: 1.15rem;
+}
+
+.state-committed {
+ border-color: #9ed8af;
+ background: #effaf2;
+}
+
+.state-unknown {
+ border-color: #e8c774;
+ background: #fff8df;
+}
+
+.state-failed_safe,
+.state-rejected {
+ border-color: #e2a2a7;
+ background: #fff1f2;
+}
+
+.facts {
+ margin: 0;
+}
+
+.facts div {
+ display: grid;
+ grid-template-columns: minmax(140px, 0.4fr) 1fr;
+ gap: 1rem;
+ padding: 0.7rem 0;
+ border-bottom: 1px solid #e4e9f1;
+}
+
+.facts dt {
+ color: #52617a;
+}
+
+.facts dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.attempts {
+ display: grid;
+ gap: 0.5rem;
+ padding-left: 1.25rem;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (max-width: 640px) {
+ .app-shell {
+ width: min(100% - 1rem, 880px);
+ padding: 1rem 0;
+ }
+
+ .auth-bar,
+ .payment-grid,
+ .facts div {
+ grid-template-columns: 1fr;
+ }
+
+ .lookup,
+ .state-card {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/apps/web/src/utils/money.ts b/apps/web/src/utils/money.ts
new file mode 100644
index 0000000..2ddb327
--- /dev/null
+++ b/apps/web/src/utils/money.ts
@@ -0,0 +1,76 @@
+/**
+ * Strict non-floating-point monetary formatting and parsing for 6-decimal USDC.
+ *
+ * Invariant: Never use JavaScript floating-point numbers (no Number(), parseFloat(), Math.round(), or * 1e6).
+ * All conversions use string manipulation and BigInt.
+ */
+
+export const USDC_DECIMALS = 6;
+export const USDC_SCALE = 1_000_000n;
+
+/**
+ * Converts human decimal USDC string to 6-decimal atomic units string.
+ * Examples:
+ * "1.5" -> "1500000"
+ * "0.000001" -> "1"
+ * "100" -> "100000000"
+ */
+export function usdcToAtomicUnits(humanUsdc: string): string {
+ const trimmed = humanUsdc.trim();
+ if (!trimmed) {
+ throw new Error('Amount cannot be empty');
+ }
+
+ if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(trimmed)) {
+ throw new Error(`Invalid USDC amount format: "${humanUsdc}"`);
+ }
+
+ const parts = trimmed.split('.');
+ const intPart = parts[0] ?? '0';
+ const fracPart = parts[1] ?? '';
+
+ if (fracPart.length > USDC_DECIMALS) {
+ throw new Error(`USDC precision exceeds ${USDC_DECIMALS} decimal places: "${humanUsdc}"`);
+ }
+
+ const paddedFrac = fracPart.padEnd(USDC_DECIMALS, '0');
+ const atomicBigInt = BigInt(intPart) * USDC_SCALE + BigInt(paddedFrac);
+
+ return atomicBigInt.toString();
+}
+
+/**
+ * Converts 6-decimal atomic units string to human decimal USDC string.
+ * Examples:
+ * "1500000" -> "1.500000"
+ * "1" -> "0.000001"
+ * "100000000" -> "100.000000"
+ */
+export function atomicUnitsToUsdc(atomicStr: string): string {
+ const trimmed = atomicStr.trim();
+ if (!/^(0|[1-9]\d*)$/.test(trimmed)) {
+ throw new Error(`Invalid atomic units format: "${atomicStr}"`);
+ }
+
+ const atomicVal = BigInt(trimmed);
+ const intVal = atomicVal / USDC_SCALE;
+ const fracVal = atomicVal % USDC_SCALE;
+
+ const fracPadded = fracVal.toString().padStart(USDC_DECIMALS, '0');
+ return `${intVal}.${fracPadded}`;
+}
+
+/**
+ * Formats atomic units for display.
+ */
+export function formatUsdcDisplay(atomicStr: string, trimTrailingZeros = false): string {
+ const full = atomicUnitsToUsdc(atomicStr);
+ if (!trimTrailingZeros) return full;
+ const parts = full.split('.');
+ const intPart = parts[0] ?? '0';
+ const fracPart = parts[1] ?? '00';
+ const trimmedFrac = fracPart.replace(/0+$/, '');
+ if (!trimmedFrac) return `${intPart}.00`;
+ if (trimmedFrac.length === 1) return `${intPart}.${trimmedFrac}0`;
+ return `${intPart}.${trimmedFrac}`;
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..92dea2d
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_ONESHOT_API_BASE_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/web/test/client.test.ts b/apps/web/test/client.test.ts
new file mode 100644
index 0000000..07c59d1
--- /dev/null
+++ b/apps/web/test/client.test.ts
@@ -0,0 +1,72 @@
+import type { CreateIntentRequest, IntentResponse } from '@oneshot/contracts';
+import { describe, expect, it } from 'vitest';
+
+import { OneShotApiClient } from '../src/api/client.js';
+
+const request: CreateIntentRequest = {
+ business_intent_id: 'intent-web-1',
+ recipient: '0x1111111111111111111111111111111111111111',
+ amount_atomic: '1000000',
+ asset: 'USDC',
+ network: 'eip155:5042002',
+ purpose: 'Paid API job',
+};
+
+const intent: IntentResponse = {
+ ...request,
+ payload_fingerprint: 'fingerprint',
+ state: 'READY',
+ version: 1,
+ attempts: [],
+ evidence: [],
+};
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+describe('OneShotApiClient', () => {
+ it('distinguishes accepted intents from identical replays', async () => {
+ let status = 202;
+ const calls: RequestInit[] = [];
+ const client = new OneShotApiClient({
+ getAuthToken: () => 'demo-token',
+ fetchFn: async (_input, init) => {
+ calls.push(init ?? {});
+ return json(status, intent);
+ },
+ });
+
+ await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({ kind: 'ACCEPTED' });
+ status = 200;
+ await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({ kind: 'REPLAYED' });
+ expect(new Headers(calls[0]?.headers).get('authorization')).toBe('Bearer demo-token');
+ });
+
+ it.each([
+ [409, 'INTENT_PAYLOAD_CONFLICT', 'PAYLOAD_CONFLICT'],
+ [401, 'UNAUTHORIZED', 'UNAUTHORIZED'],
+ [429, 'RATE_LIMITED', 'RATE_LIMITED'],
+ [503, 'NOT_READY', 'NOT_READY'],
+ ] as const)('maps HTTP %s to %s', async (status, code, kind) => {
+ const client = new OneShotApiClient({
+ fetchFn: async () => json(status, { code, message: code, correlation_id: 'corr-1' }),
+ });
+ await expect(client.createOrReplayIntent(request, 'corr-1')).resolves.toMatchObject({ kind });
+ });
+
+ it('fails closed on a network error', async () => {
+ const client = new OneShotApiClient({
+ fetchFn: async () => {
+ throw new Error('offline');
+ },
+ });
+ await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({
+ kind: 'ERROR',
+ code: 'NETWORK_ERROR',
+ });
+ });
+});
diff --git a/apps/web/test/components.test.tsx b/apps/web/test/components.test.tsx
new file mode 100644
index 0000000..49b10e9
--- /dev/null
+++ b/apps/web/test/components.test.tsx
@@ -0,0 +1,123 @@
+import type { IntentResponse, IntentState } from '@oneshot/contracts';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import axe from 'axe-core';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { OneShotApiClient } from '../src/api/client.js';
+import { IntentForm } from '../src/components/IntentForm.js';
+import { IntentStatusView } from '../src/components/IntentStatusView.js';
+
+afterEach(cleanup);
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+function intent(state: IntentState): IntentResponse {
+ return {
+ business_intent_id: 'intent-web-1',
+ recipient: '0x1111111111111111111111111111111111111111',
+ amount_atomic: '1000000',
+ asset: 'USDC',
+ network: 'eip155:5042002',
+ purpose: 'Paid API job',
+ payload_fingerprint: 'fingerprint',
+ state,
+ version: 1,
+ attempts: [],
+ evidence: [],
+ };
+}
+
+describe('IntentForm', () => {
+ it('preserves the stable ID and explains an identical replay', async () => {
+ const client = new OneShotApiClient({
+ fetchFn: async (_input, init) => {
+ const body = JSON.parse(String(init?.body)) as { business_intent_id: string };
+ return json(200, { ...intent('READY'), business_intent_id: body.business_intent_id });
+ },
+ });
+ const user = userEvent.setup();
+ render( );
+
+ const id = screen.getByLabelText(/Business Intent ID/u) as HTMLInputElement;
+ const stableId = id.value;
+ await user.type(
+ screen.getByLabelText(/Recipient/u),
+ '0x1111111111111111111111111111111111111111',
+ );
+ await user.click(screen.getByRole('button', { name: /Submit Intent/u }));
+
+ expect(await screen.findByText(/REPLAYED/u)).toBeTruthy();
+ expect(id.value).toBe(stableId);
+ });
+
+ it('shows payload conflict without generating another ID', async () => {
+ const client = new OneShotApiClient({
+ fetchFn: async () =>
+ json(409, {
+ code: 'INTENT_PAYLOAD_CONFLICT',
+ message: 'different payload',
+ correlation_id: 'corr-1',
+ }),
+ });
+ const user = userEvent.setup();
+ render( );
+ const id = screen.getByLabelText(/Business Intent ID/u) as HTMLInputElement;
+ const stableId = id.value;
+ await user.type(
+ screen.getByLabelText(/Recipient/u),
+ '0x1111111111111111111111111111111111111111',
+ );
+ await user.click(screen.getByRole('button', { name: /Submit Intent/u }));
+
+ expect(await screen.findByText(/PAYLOAD CONFLICT/u)).toBeTruthy();
+ expect(id.value).toBe(stableId);
+ });
+
+ it('has no detectable structural accessibility violations', async () => {
+ const client = new OneShotApiClient({ fetchFn: async () => json(503, {}) });
+ const { container } = render( );
+ expect(
+ (await axe.run(container, { rules: { 'color-contrast': { enabled: false } } })).violations,
+ ).toEqual([]);
+ });
+});
+
+describe('IntentStatusView', () => {
+ it.each([
+ 'AUTHORIZING',
+ 'READY',
+ 'SUBMITTING',
+ 'COMMITTED',
+ 'FAILED_SAFE',
+ 'UNKNOWN',
+ 'REJECTED',
+ ] as const)('renders authoritative %s state', async (state) => {
+ const client = new OneShotApiClient({ fetchFn: async () => json(200, intent(state)) });
+ render( );
+ await waitFor(() => expect(screen.getByText(state)).toBeTruthy());
+ });
+
+ it('holds UNKNOWN and only offers reconciliation', async () => {
+ const client = new OneShotApiClient({
+ fetchFn: async (input, init) => {
+ if (String(input).endsWith('/reconcile') && init?.method === 'POST') {
+ return json(202, { business_intent_id: 'intent-web-1', queued: true, state: 'UNKNOWN' });
+ }
+ return json(200, intent('UNKNOWN'));
+ },
+ });
+ const user = userEvent.setup();
+ render( );
+
+ const reconcile = await screen.findByRole('button', { name: /Enqueue Reconciliation/u });
+ expect(screen.queryByRole('button', { name: /pay|retry settlement/iu })).toBeNull();
+ await user.click(reconcile);
+ expect(await screen.findByText(/job enqueued/u)).toBeTruthy();
+ });
+});
diff --git a/apps/web/test/money.test.ts b/apps/web/test/money.test.ts
new file mode 100644
index 0000000..eb75d10
--- /dev/null
+++ b/apps/web/test/money.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+
+import { atomicUnitsToUsdc, formatUsdcDisplay, usdcToAtomicUnits } from '../src/utils/money.js';
+
+describe('USDC formatting', () => {
+ it.each([
+ ['0.000001', '1'],
+ ['1', '1000000'],
+ ['1.25', '1250000'],
+ ['999999999999999999.999999', '999999999999999999999999'],
+ ])('round-trips %s without floating point', (human, atomic) => {
+ expect(usdcToAtomicUnits(human)).toBe(atomic);
+ expect(usdcToAtomicUnits(atomicUnitsToUsdc(atomic))).toBe(atomic);
+ });
+
+ it.each(['', '-1', '1e6', '1.0000001', '01'])('rejects invalid amount %s', (amount) => {
+ expect(() => usdcToAtomicUnits(amount)).toThrow();
+ });
+
+ it('formats display values without losing atomic precision', () => {
+ expect(formatUsdcDisplay('1500000', true)).toBe('1.50');
+ expect(formatUsdcDisplay('1000001', true)).toBe('1.000001');
+ });
+});
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..fa2df75
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "tsBuildInfoFile": "dist/.tsbuildinfo",
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "references": [{ "path": "../../packages/contracts" }]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..2523ea6
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,23 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3000,
+ proxy: {
+ '/v1': {
+ target: 'http://127.0.0.1:3001',
+ changeOrigin: true,
+ },
+ '/health': {
+ target: 'http://127.0.0.1:3001',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ sourcemap: false,
+ outDir: 'dist',
+ },
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 0000000..203c68a
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ include: ['test/**/*.test.{ts,tsx}'],
+ restoreMocks: true,
+ },
+});
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3891cda..400fa50 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -14,7 +14,7 @@ export default tseslint.config(
},
},
{
- files: ['**/*.ts'],
+ files: ['**/*.ts', '**/*.tsx'],
rules: {
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
diff --git a/package.json b/package.json
index 97342a5..05d2328 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,8 @@
"build": "tsc -b",
"check:generated": "pnpm --filter @oneshot/contracts check:generated",
"clean": "tsc -b --clean",
- "deploy": "wrangler deploy",
- "dev:frontend": "wrangler dev",
+ "deploy": "pnpm --filter @oneshot/web build && wrangler deploy",
+ "dev:frontend": "pnpm --filter @oneshot/web dev",
"format": "prettier --write \"**/*.{ts,mts,mjs,json,jsonc,yml,yaml}\"",
"format:check": "prettier --check \"**/*.{ts,mts,mjs,json,jsonc,yml,yaml}\"",
"generate": "pnpm --filter @oneshot/contracts generate",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 468543b..9e23ecc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,7 +37,7 @@ importers:
version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
vitest:
specifier: 5.0.0
- version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+ version: 5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
wrangler:
specifier: 4.127.0
version: 4.127.0
@@ -61,6 +61,52 @@ importers:
specifier: 12.1.0
version: 12.1.0(supports-color@7.2.0)
+ apps/web:
+ dependencies:
+ '@oneshot/contracts':
+ specifier: workspace:*
+ version: link:../../packages/contracts
+ react:
+ specifier: 19.2.8
+ version: 19.2.8
+ react-dom:
+ specifier: 19.2.8
+ version: 19.2.8(react@19.2.8)
+ devDependencies:
+ '@testing-library/react':
+ specifier: 16.3.3
+ version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@testing-library/user-event':
+ specifier: 14.6.7
+ version: 14.6.7(@testing-library/dom@10.4.1)
+ '@types/node':
+ specifier: 24.13.3
+ version: 24.13.3
+ '@types/react':
+ specifier: 19.2.18
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: 19.2.7
+ version: 19.2.7(@types/react@19.2.18)
+ '@vitejs/plugin-react':
+ specifier: 6.1.1
+ version: 6.1.1(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+ axe-core:
+ specifier: 4.13.0
+ version: 4.13.0
+ jsdom:
+ specifier: 30.0.1
+ version: 30.0.1(@noble/hashes@1.8.0)
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vite:
+ specifier: 8.0.0
+ version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
+ vitest:
+ specifier: 5.0.0
+ version: 5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+
apps/worker:
dependencies:
'@oneshot/arc-adapter':
@@ -174,7 +220,7 @@ importers:
version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)
vitest:
specifier: 5.0.0
- version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
+ version: 5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
packages/storage-postgres:
dependencies:
@@ -1072,6 +1118,12 @@ packages:
'@types/ssh2@1.15.6':
resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
+ '@types/ws@8.18.1':
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
'@typescript-eslint/eslint-plugin@8.69.0':
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1339,6 +1391,10 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -1480,6 +1536,10 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
@@ -1673,6 +1733,10 @@ packages:
engines: {node: '>=14.0.0', yarn: ^1.22.22}
hasBin: true
+ happy-dom@20.14.0:
+ resolution: {integrity: sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==}
+ engines: {node: '>=20.0.0'}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -2554,6 +2618,10 @@ packages:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
@@ -3353,6 +3421,14 @@ snapshots:
dependencies:
'@types/node': 18.19.130
+ '@types/whatwg-mimetype@3.0.2':
+ optional: true
+
+ '@types/ws@8.18.1':
+ dependencies:
+ '@types/node': 24.13.3
+ optional: true
+
'@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(supports-color@7.2.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.10.0(supports-color@7.2.0))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3618,6 +3694,11 @@ snapshots:
buffer-crc32@1.0.0: {}
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 24.13.3
+ optional: true
+
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -3794,6 +3875,9 @@ snapshots:
dependencies:
once: 1.4.0
+ entities@7.0.1:
+ optional: true
+
entities@8.0.0: {}
error-ex@1.3.4:
@@ -4056,6 +4140,20 @@ snapshots:
- supports-color
- typescript
+ happy-dom@20.14.0:
+ dependencies:
+ '@types/node': 24.13.3
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.1
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+ optional: true
+
has-flag@4.0.0: {}
hashery@1.5.1:
@@ -4937,7 +5035,7 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
- vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)):
+ vitest@5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))
@@ -4955,6 +5053,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
+ happy-dom: 20.14.0
jsdom: 30.0.1(@noble/hashes@1.8.0)
transitivePeerDependencies:
- msw
@@ -4965,6 +5064,9 @@ snapshots:
webidl-conversions@8.0.1: {}
+ whatwg-mimetype@3.0.0:
+ optional: true
+
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1(@noble/hashes@1.8.0):
diff --git a/tsconfig.json b/tsconfig.json
index 6a69570..9f9125a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -33,6 +33,9 @@
},
{
"path": "./packages/recovery-ui"
+ },
+ {
+ "path": "./apps/web"
}
]
}
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 15032de..2850434 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -2,8 +2,12 @@
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "oneshot",
"compatibility_date": "2026-09-07",
+ "build": {
+ "command": "pnpm --filter @oneshot/web build",
+ },
"assets": {
- "directory": "./apps/placeholder-frontend",
+ "directory": "./apps/web/dist",
+ "not_found_handling": "single-page-application",
},
"routes": [
{