diff --git a/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md new file mode 100644 index 0000000..13f7af5 --- /dev/null +++ b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md @@ -0,0 +1,177 @@ +# Session Context: B05 Frontend Authorization and Settlement Details + +## Date/time + +- UTC: 2026-09-08T18:08:39Z + +## User goal + +Deliver milestone B05: an independently composable Lane B frontend slice that +explains Privy policy, authorization state, settlement state, and verified Arc +transaction evidence from sanitized API fields, with no bypass action and no +secret exposure. + +## Original prompt/request + +"check if everything is ready to do b05 step" followed by "start" after the +readiness audit confirmed Gate P4 froze the frontend boundary. + +## Assumptions + +- Gate P4 frontend freeze (PR #34) satisfies the B05 start gate. `docs/GATE_P4_CHECKLIST.md` + step 4 is `[COMPLETED]` and names A05/B05/C05 as unblocked. +- The frozen mock server `@oneshot/contracts` `OPENAPI_MOCK_SERVER_VERSION = '1.0.0'` + and `packages/contracts/fixtures/ui/v1/` are the contract host for this slice. +- Published contract fixture digests are immutable, so states B05.3 requires that + the frozen pack does not carry (`READY`, `SUBMITTING`, on-chain revert, + unavailable evidence) are added as B-owned package-local fixtures built from the + frozen schema rather than by editing `packages/contracts`. +- `@oneshot/recovery-ui` (C05) is the composition precedent: a lane-owned package + exporting entry points, with no edit to the A-owned app shell. +- Live Arc testnet evidence is still `LIVE_NOT_RUN` and human-gated. B05 closes on + fixtures, matching how A05 and C05 closed. + +## Plan + +1. Create `packages/settlement-ui` (`@oneshot/settlement-ui`) mirroring the + `@oneshot/recovery-ui` package shape. +2. B05.1 policy summary from `IntentResponse.policy` sanitized fields. +3. B05.2 authorization states from `Attempt.authorization_status`. +4. B05.3 settlement states with `UNKNOWN` disabling any new settlement action and + no confirmation counts. +5. B05.4 verified transaction details with validated explorer URL. +6. B05.5 component/contract/route/redaction/accessibility tests plus package-local + lint, type, test, and build checks. +7. Publish README handoff artifact, run root checks, record evidence, Gate A, + draft PR, CI, Gate B. + +## Key decisions + +- Money renders through package-local `bigint` string arithmetic. No import from + the A-owned `apps/web`, and no JavaScript floating point. +- Explorer links are validated before the anchor renders: https only, no embedded + credentials, a host on the allowlist, and the rendered transaction hash present + in the link. A link that fails any rule is dropped, not rendered inert. The + default allowlist holds the documented Arc testnet explorer host; deployments + and fixture viewers pass their own list rather than widening it. +- The displayed attempt is chosen by latest `created_at`, because the contract + does not promise that `attempts` is ordered. +- The slice exposes no submit, resend, force-pay, or adapter action. Reconciliation + is a read-only trigger owned by Lane C's timeline, so B05 renders state only. +- Test files use `.ts` with `createElement` (the C05 convention) so the root + `vitest` include pattern runs them. + +## Files/components touched + +- `packages/settlement-ui/`: new Lane B frontend slice. +- `tsconfig.json`: add the new project reference slot. +- `pnpm-lock.yaml`: workspace lockfile for the new package. + +## Commands/checks + +- `git fetch origin develop` - PASS +- `git rev-parse origin/develop` - `710614af76ae5c28e2c1f69b2c00480f47b623b7` +- `git checkout -b milestone/b05-frontend-settlement-details origin/develop` - PASS +- `pnpm install` - PASS (adds the new workspace package) +- `pnpm --filter @oneshot/settlement-ui verify` - PASS (format, lint, typecheck, 186 tests, build) +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm test` - PASS (53 files, 791 tests; 49 files and 605 tests on the base) +- `pnpm check:generated` - PASS +- `pnpm validate:fixtures` - PASS (9 contracts-v1 and 7 ui-v1 fixtures) +- `npx markdownlint-cli2` on the added Markdown - PASS +- `pnpm format:check` - FAILS on `subgraph/generated/ArcTestnetUSDC/ERC20.ts` and + `subgraph/generated/schema.ts`. Pre-existing and unrelated: those files are + produced by subgraph codegen, ignored by `subgraph/.gitignore`, and absent from + `.prettierignore`, so the root check fails on any machine that has run codegen. + No file in this branch is affected; `prettier --check` over the changed paths + passes. Left for the owning lane rather than editing shared root config here. + +## External-doc findings + +- `docs/GATE_P4_MANIFEST.md`: OpenAPI digest + `f639e2d2729cd061d606cd35eb83961c58067a3660ecc5437c0f4596c88edc2c`, + mock server `1.0.0`, additive B05 fields `IntentResponse.policy`, + `Attempt.authorization_status`, `Settlement.token_contract`, + `Settlement.explorer_url`. +- `packages/contracts/openapi/openapi.v1.json`: `explorer_url` is a bounded string + with no scheme constraint, so URL validation is the consumer's responsibility. + +## Test matrix cases selected + +Read-only UI slice. Applicable cases from `.agent/TEST_MATRIX.md`: + +- Privy denial: denial and cap-exceeded fixtures render an explicit authorization + failure and expose zero settlement actions. +- Crash after submission / lost payment response: the `UNKNOWN` fixture renders as + non-terminal and offers no new settlement action. +- Graph delay, absence, or ambiguity: lagging and unavailable evidence render as + observation, never as proof of non-payment. + +Cross-cutting assertions covered here: monetary values formatted from integer +atomic units through `bigint`; fixtures contain no secret or wallet material. + +Backend-only cases (parallel worker storm, restart, two agents) are out of scope +for a presentational slice and remain proven by A03/A04 and Gate P4. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/b05-frontend-settlement-details` +- Base: `develop` (`710614af76ae5c28e2c1f69b2c00480f47b623b7`) +- Commit: `2fbc8bd9380eb18f1aeff192b84867fbfbc906c5` (28 files, +2913 insertions), + plus a second candidate for the Gate A non-blocking findings, staged and + captured with `git write-tree` immediately before the second Gate A +- All changes are additive except the `tsconfig.json` project reference slot and + the `pnpm-lock.yaml` workspace entry +- PR: (draft, base `develop`) +- CI on `2fbc8bd`: ESLint and TypeScript PASS, Markdown and Mermaid PASS, + Workers Builds PASS, repository-policy PASS + +## Review gates + +- Gate A (round 1): PASS. Tool `free-pi-cli`, model `deepseek-v4-flash` + (provider: free-pi), base `710614af76ae5c28e2c1f69b2c00480f47b623b7`, tree + `fe84cc22695d62f19be8ff83b6da9afd03fb3927`. No blocking findings; three + non-blocking findings, all now addressed: + 1. Explorer host was not pinned. `validateExplorerUrl` now takes an allowlist + defaulting to `DEFAULT_EXPLORER_HOSTS` + (`testnet.arcscan.app`, the host documented in + `docs/settlement/PROVIDER_SETUP.md`), and rejects every other host. + `SettlementDetailsRoute` accepts `allowedExplorerHosts` for deployments + with a different explorer. The synthetic fixtures use + `testnet.arcscan.io`, so `FIXTURE_EXPLORER_HOSTS` makes the demo viewer and + component tests opt into that host rather than widening the default. + 2. Line count corrected: the first commit is 28 files and +2913 insertions. + 3. `attempts.at(-1)` replaced with selection by latest `created_at`, keeping + the later element on a tie and never letting an undated attempt displace a + dated one. +- Gate A (round 2): NOT RUN for the new candidate tree. +- Gate B: NOT RUN + +## Gate A instruction message + +Run `npx free-pi-cli` from the repository root in a fresh process and send one +message: + +> Read `.agent/review-prompts/freepi-prepush-review.md` and follow it. +> Base: `710614af76ae5c28e2c1f69b2c00480f47b623b7` (origin/develop). +> Candidate tree: the SHA printed by `git write-tree`, staged index. The index +> contains pushed commit `2fbc8bd9380eb18f1aeff192b84867fbfbc906c5` plus the +> round-two fixes, so review the whole branch diff against the base. +> Branch: `milestone/b05-frontend-settlement-details`. +> Acceptance criteria: milestone B05 in +> `milestones/coder-b/B05-frontend-settlement-details.md`. + +## Handoff/next steps + +1. Capture `git write-tree`, run FreePi Gate A against that tree, and require an + explicit `VERDICT: PASS`. +2. Commit the staged tree unchanged, confirm `git rev-parse "HEAD^{tree}"` equals + the reviewed tree, and push the branch. +3. Open a draft PR targeting `develop` with the Gate A evidence, wait for every + required check on the exact head SHA, then run FreePi Gate B. +4. Mark ready for human review. Never merge. diff --git a/packages/settlement-ui/README.md b/packages/settlement-ui/README.md new file mode 100644 index 0000000..a5c5567 --- /dev/null +++ b/packages/settlement-ui/README.md @@ -0,0 +1,126 @@ +# OneShot Settlement UI + +`@oneshot/settlement-ui` is the independently composable B05 slice for Privy +authorization and Arc settlement details. It renders sanitized API fields only +and exposes no settlement, retry, resend, or policy-override action. + +## Entry points + +- `SettlementDetailsRoute`: the route slot. Reads one Business Intent through an + injected `SettlementClient` and renders the composed slice. +- `SettlementDetailsPanel`: presentational composition for a shell that already + holds a projected view. +- `PolicySummaryPanel`, `AuthorizationStatePanel`, `SettlementStatePanel`, + `TransactionDetails`: the individual panels, mountable on their own. +- `toSettlementDetailsView`: projects a frozen `IntentResponse` into the display + model used by every component here. +- `createSettlementClient`: HTTP client for the frozen OpenAPI seam. +- `createMockSettlementClient`: the same client bound to the frozen mock server. +- `createInMemorySettlementClient`: deterministic client for component tests. +- `validateExplorerUrl`, `assertNoSensitiveFields`, `formatAtomicUsdc`: the + boundary rules the panels are built on. +- `DEFAULT_EXPLORER_HOSTS`: the explorer hosts a link may point at unless the + caller supplies its own list. + +## Composition note + +The shell owns routing. Mount the route slot where settlement details belong and +pass the identifier the shell already resolved: + +```tsx +import { SettlementDetailsRoute, createSettlementClient } from '@oneshot/settlement-ui'; +import '@oneshot/settlement-ui/styles.css'; + +const client = createSettlementClient({ + baseUrl: import.meta.env.VITE_ONESHOT_API_BASE_URL ?? '', + getAuthToken: () => sessionToken, +}); + +; +``` + +A deployment whose explorer differs from `DEFAULT_EXPLORER_HOSTS` passes its own +list through `allowedExplorerHosts`. An empty list disables explorer links. + +This package does not edit the application shell or its route registry, so Gate +P5 composition stays a single-editor change in the shell. + +## Contract binding + +- OpenAPI: frozen v1 seam, digest + `f639e2d2729cd061d606cd35eb83961c58067a3660ecc5437c0f4596c88edc2c`. +- Mock server: `@oneshot/contracts` `OPENAPI_MOCK_SERVER_VERSION` `1.0.0`, + re-exported here as `SETTLEMENT_UI_MOCK_SERVER_VERSION`. +- Fields consumed: `IntentResponse.policy`, `AttemptView.authorization_status`, + `SettlementView.token_contract`, `SettlementView.explorer_url`, plus the + base intent, attempt, settlement, and evidence fields. +- View contract version: `settlement-details-v1`. + +## Safety rules this slice enforces + +- **No bypass.** No component renders a submit, resubmit, force-pay, or + policy-override control. The client interface is read-only by construction. +- **`UNKNOWN` is not terminal.** It renders as not final, with no settlement + action and no confirmation count. Arc is shown as pending or final only. +- **Verified evidence only.** Transaction details render when the durable state + is `COMMITTED`, the Arc identity is well formed, and an authoritative Arc + observation exists. Anything less renders as unverified with details withheld. +- **Validated outbound links.** An explorer URL becomes an `href` only if it is + https, carries no embedded credentials, sits on the host allowlist, and + references the exact transaction hash being displayed. Hash binding alone is + not enough, because a hostile host can quote the real hash back. Anything else + is dropped with a stated reason. +- **Fail-closed redaction.** A response carrying a secret-shaped field name is + refused before projection, and the route renders "Response withheld" instead + of any part of it. +- **Exact money.** Amounts are formatted from integer atomic units with `bigint` + string arithmetic. A malformed amount renders as malformed, never as a + rounded number. + +## Fixtures + +Seven scenarios come from the frozen contract pack +(`packages/contracts/fixtures/ui/v1/`). Five more are Lane B local fixtures for +states the frozen pack does not carry, since published fixture digests are +immutable: + +| Scenario | Source | Covers | +| -------------------------------- | ------ | --------------------------------------------- | +| `authorized-committed` | frozen | Verified settlement with explorer link | +| `auth-checking` | frozen | Authorization in progress | +| `auth-denied-recipient` | frozen | Recipient not on the allowlist | +| `auth-cap-exceeded` | frozen | Amount above the per-settlement cap | +| `auth-unavailable` | frozen | Authorization service unavailable | +| `auth-config-mismatch` | frozen | Policy configuration mismatch | +| `unknown-reconcile-only` | frozen | `UNKNOWN` with lagging index evidence | +| `ready-authorized` | Lane B | Authorized, nothing submitted | +| `submitting-in-flight` | Lane B | Attempt crossing the provider boundary | +| `final-revert` | Lane B | Reverted transaction, no committed settlement | +| `hostile-explorer-link` | Lane B | Unsafe explorer URL and hostile strings | +| `committed-without-arc-evidence` | Lane B | Committed record without Arc proof | + +The synthetic fixtures publish links through `testnet.arcscan.io`, which is not +the documented Arc testnet explorer. `FIXTURE_EXPLORER_HOSTS` exists so the +fixture viewer and component tests opt into that host explicitly instead of the +package widening its default allowlist. + +Run the standalone fixture viewer: + +```bash +pnpm --filter @oneshot/settlement-ui dev +``` + +Open `/?scenario=unknown-reconcile-only`. Any key of `SETTLEMENT_SCENARIOS` may +be selected. Its banner marks all data as synthetic review fixtures; it is not +live sponsor evidence. + +## Checks + +```bash +pnpm --filter @oneshot/settlement-ui verify +``` + +This runs format, lint, typecheck, test, and build. Tests cover every fixture, +redaction, malicious strings and URLs, unavailable evidence, exact amount +formatting, keyboard reachability, responsive breakpoints, and an `axe-core` +accessibility scan of every scenario. diff --git a/packages/settlement-ui/index.html b/packages/settlement-ui/index.html new file mode 100644 index 0000000..cac6eb5 --- /dev/null +++ b/packages/settlement-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + OneShot authorization and settlement details + + +
+ + + diff --git a/packages/settlement-ui/package.json b/packages/settlement-ui/package.json new file mode 100644 index 0000000..d6792db --- /dev/null +++ b/packages/settlement-ui/package.json @@ -0,0 +1,51 @@ +{ + "name": "@oneshot/settlement-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Composable Privy authorization and Arc settlement details UI for OneShot.", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/settlement-ui.js" + }, + "./styles.css": "./dist/settlement-ui.css" + }, + "sideEffects": [ + "**/*.css" + ], + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -b && vite build", + "clean": "tsc -b --clean", + "dev": "vite", + "format": "prettier --check --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"", + "format:write": "prettier --write --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"", + "lint": "eslint src test vite.config.ts", + "test": "vitest run", + "typecheck": "tsc -b --pretty false", + "verify": "pnpm run format && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build" + }, + "dependencies": { + "@oneshot/contracts": "workspace:*", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@testing-library/dom": "10.4.1", + "@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/packages/settlement-ui/src/AuthorizationStatePanel.tsx b/packages/settlement-ui/src/AuthorizationStatePanel.tsx new file mode 100644 index 0000000..8e8bd20 --- /dev/null +++ b/packages/settlement-ui/src/AuthorizationStatePanel.tsx @@ -0,0 +1,93 @@ +import type { AuthorizationDisplay, AuthorizationDisplayStatus } from './contract.js'; + +export interface AuthorizationStatePanelProps { + readonly authorization: AuthorizationDisplay; +} + +const STATUS_LABELS: Readonly> = { + CHECKING: 'Checking', + AUTHORIZED: 'Authorized', + DENIED: 'Denied', + UNAVAILABLE: 'Unavailable', + CONFIG_MISMATCH: 'Configuration mismatch', + NOT_REPORTED: 'Not reported', +}; + +const STATUS_TONE: Readonly> = { + CHECKING: 'pending', + AUTHORIZED: 'success', + DENIED: 'danger', + UNAVAILABLE: 'warning', + CONFIG_MISMATCH: 'danger', + NOT_REPORTED: 'warning', +}; + +/** + * Each explanation states what the status means for settlement rights. None of + * them describes a way around the outcome: this interface exposes no override, + * and a denial is an outcome to read, not an obstacle to route around. + */ +const STATUS_EXPLANATIONS: Readonly> = { + CHECKING: + 'Privy is evaluating this attempt against the wallet policy. Nothing is authorized yet.', + AUTHORIZED: 'Privy permitted exactly the requested scope for this attempt.', + DENIED: 'Privy refused this attempt. No settlement was submitted and this attempt is closed.', + UNAVAILABLE: + 'Authorization could not be evaluated. This is neither an approval nor a denial, and the attempt failed safe without submitting.', + CONFIG_MISMATCH: + 'The expected wallet, policy, network, or token identity did not match the configured values, so authorization stopped before any submission.', + NOT_REPORTED: 'The latest attempt carries no authorization status.', +}; + +const STATUS_NOTE: Readonly>> = { + DENIED: 'OneShot cannot override a Privy denial, and this interface offers no bypass.', + CONFIG_MISMATCH: 'Identity checks fail closed. No settlement is possible while they disagree.', + UNAVAILABLE: 'An unavailable authorization never grants a submission right.', +}; + +/** + * B05.2 authorization states. + * + * `CHECKING`, `AUTHORIZED`, `DENIED`, `UNAVAILABLE`, and `CONFIG_MISMATCH` are + * distinct and separately explained, because collapsing "denied" into + * "unavailable" would read a hard refusal as a retryable blip. + */ +export function AuthorizationStatePanel({ authorization }: AuthorizationStatePanelProps) { + const note = STATUS_NOTE[authorization.status] ?? null; + + return ( +
+
+

Authorization

+ + {STATUS_LABELS[authorization.status]} + +
+

{STATUS_EXPLANATIONS[authorization.status]}

+ {authorization.sanitizedReason !== null && ( +

+ Reported reason + {authorization.sanitizedReason} +

+ )} + {note !== null &&

{note}

} +
+
+
Attempt
+
{authorization.attemptId ?? 'None recorded'}
+
+
+
Recorded
+
{authorization.occurredAt ?? 'Not recorded'}
+
+
+
Attempt outcome
+
{authorization.terminal ? 'Closed for this attempt' : 'Open'}
+
+
+
+ ); +} diff --git a/packages/settlement-ui/src/DemoShell.tsx b/packages/settlement-ui/src/DemoShell.tsx new file mode 100644 index 0000000..f76c001 --- /dev/null +++ b/packages/settlement-ui/src/DemoShell.tsx @@ -0,0 +1,61 @@ +import { useMemo, useState } from 'react'; + +import { createInMemorySettlementClient } from './client.js'; +import { + FIXTURE_EXPLORER_HOSTS, + SETTLEMENT_SCENARIOS, + SETTLEMENT_SCENARIO_INTENTS, +} from './fixtures.js'; +import { SettlementDetailsRoute } from './SettlementDetailsRoute.js'; + +export interface DemoShellProps { + readonly initialScenario?: string; +} + +const SCENARIOS = Object.values(SETTLEMENT_SCENARIOS); +const DEFAULT_SCENARIO = SCENARIOS[0]?.scenario ?? ''; + +/** + * Standalone fixture viewer for reviewing the slice without the application + * shell. Every value on screen comes from synthetic fixtures. + */ +export function DemoShell({ initialScenario }: DemoShellProps) { + const [scenarioName, setScenarioName] = useState(initialScenario ?? DEFAULT_SCENARIO); + const client = useMemo(() => createInMemorySettlementClient(SETTLEMENT_SCENARIO_INTENTS), []); + const scenario = SETTLEMENT_SCENARIOS[scenarioName] ?? SETTLEMENT_SCENARIOS[DEFAULT_SCENARIO]; + + if (scenario === undefined) { + return ( +
+

No fixtures are published.

+
+ ); + } + + return ( + <> +
+ Synthetic review fixtures. Not live settlement evidence. +
+ + +
+
+ + + ); +} diff --git a/packages/settlement-ui/src/PolicySummaryPanel.tsx b/packages/settlement-ui/src/PolicySummaryPanel.tsx new file mode 100644 index 0000000..aa98135 --- /dev/null +++ b/packages/settlement-ui/src/PolicySummaryPanel.tsx @@ -0,0 +1,121 @@ +import type { PolicyDisplayStatus, PolicySummaryDisplay } from './contract.js'; + +export interface PolicySummaryPanelProps { + readonly policy: PolicySummaryDisplay; +} + +const POLICY_STATUS_LABELS: Readonly> = { + CONFIGURED: 'Configured', + EXCEEDED: 'Cap exceeded', + NOT_CONFIGURED: 'Not configured', + UNKNOWN: 'Unknown', + NOT_REPORTED: 'Not reported', +}; + +const POLICY_STATUS_TONE: Readonly> = { + CONFIGURED: 'success', + EXCEEDED: 'danger', + NOT_CONFIGURED: 'danger', + UNKNOWN: 'warning', + NOT_REPORTED: 'warning', +}; + +const POLICY_STATUS_EXPLANATIONS: Readonly> = { + CONFIGURED: 'Privy reports an active spending policy for this wallet.', + EXCEEDED: 'The requested amount is above the approved per-settlement cap.', + NOT_CONFIGURED: 'No matching Privy policy is configured, so no settlement can be authorized.', + UNKNOWN: 'Privy policy state could not be read. Unknown is not treated as permitted.', + NOT_REPORTED: 'The API returned no policy summary for this intent.', +}; + +function allowlistLabel(allowlisted: boolean | null): string { + if (allowlisted === null) return 'No allowlist reported'; + return allowlisted ? 'On the allowlist' : 'Not on the allowlist'; +} + +function capLabel(policy: PolicySummaryDisplay): string { + if (policy.settlementCapDisplay === null) return 'Not reported'; + return `${policy.settlementCapDisplay} ${policy.asset}`; +} + +function withinCapLabel(withinCap: boolean | null): string { + if (withinCap === null) return 'Cannot be compared'; + return withinCap ? 'At or under the cap' : 'Above the cap'; +} + +/** + * B05.1 policy summary. + * + * Renders only sanitized policy fields the API publishes. Authorization keys, + * signatures, owner material, and raw provider payloads have no prop here and + * no path to the DOM. + */ +export function PolicySummaryPanel({ policy }: PolicySummaryPanelProps) { + return ( +
+
+

Policy

+ + {POLICY_STATUS_LABELS[policy.status]} + +
+

{POLICY_STATUS_EXPLANATIONS[policy.status]}

+
+
+
Network
+
{policy.network}
+
+
+
Asset
+
{policy.asset}
+
+
+
Recipient
+
+ {policy.recipient}{' '} + + {allowlistLabel(policy.recipientAllowlisted)} + +
+
+
+
Per-settlement cap
+
{capLabel(policy)}
+
+
+
Requested amount
+
+ {policy.amountDisplay === null ? ( + 'Malformed amount' + ) : ( + <> + {policy.amountDisplay} {policy.asset} + {withinCapLabel(policy.amountWithinCap)} + + )} +
+
+ {policy.policyId !== null && ( +
+
Policy reference
+
{policy.policyId}
+
+ )} + {policy.allowedRecipients.length > 0 && ( +
+
Allowed recipients
+
+
    + {policy.allowedRecipients.map((address) => ( +
  • + {address} +
  • + ))} +
+
+
+ )} +
+
+ ); +} diff --git a/packages/settlement-ui/src/SettlementDetails.tsx b/packages/settlement-ui/src/SettlementDetails.tsx new file mode 100644 index 0000000..3e4a6d1 --- /dev/null +++ b/packages/settlement-ui/src/SettlementDetails.tsx @@ -0,0 +1,33 @@ +import { AuthorizationStatePanel } from './AuthorizationStatePanel.js'; +import type { SettlementDetailsView } from './contract.js'; +import { PolicySummaryPanel } from './PolicySummaryPanel.js'; +import { SettlementStatePanel } from './SettlementStatePanel.js'; +import { TransactionDetails } from './TransactionDetails.js'; + +export interface SettlementDetailsPanelProps { + readonly view: SettlementDetailsView; +} + +/** + * The composed B05 slice: policy, authorization, settlement state, and verified + * transaction evidence for one Business Intent. + * + * It renders already-projected data and owns no fetching, so a shell can mount + * it directly at Gate P5. It exposes no control that could submit, resubmit, or + * force a settlement. + */ +export function SettlementDetailsPanel({ view }: SettlementDetailsPanelProps) { + return ( +
+
+

ONESHOT / AUTHORIZATION AND SETTLEMENT

+

{view.businessIntentId}

+ {view.purpose !== null &&

{view.purpose}

} +
+ + + + +
+ ); +} diff --git a/packages/settlement-ui/src/SettlementDetailsRoute.tsx b/packages/settlement-ui/src/SettlementDetailsRoute.tsx new file mode 100644 index 0000000..c362d6f --- /dev/null +++ b/packages/settlement-ui/src/SettlementDetailsRoute.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; + +import { + SettlementClientError, + type SettlementClient, + type SettlementClientFailure, +} from './client.js'; +import { + SanitizationError, + toSettlementDetailsView, + type SettlementDetailsView, + type SettlementViewOptions, +} from './contract.js'; +import { SettlementDetailsPanel } from './SettlementDetails.js'; + +export interface SettlementDetailsRouteProps { + readonly businessIntentId: string; + readonly client: SettlementClient; + /** Explorer hosts this deployment publishes links for. */ + readonly allowedExplorerHosts?: readonly string[]; +} + +type RouteState = + | { readonly kind: 'LOADING' } + | { readonly kind: 'LOADED'; readonly view: SettlementDetailsView } + | { readonly kind: 'FAILED'; readonly heading: string; readonly detail: string }; + +const FAILURE_COPY: Readonly> = + { + INTENT_NOT_FOUND: { + heading: 'Business Intent not found', + detail: 'No intent with this identifier exists in OneShot durable state.', + }, + EVIDENCE_UNAVAILABLE: { + heading: 'Evidence unavailable', + detail: + 'Provider and index evidence could not be read. The authoritative OneShot state is unchanged, and unavailable evidence is not proof that no payment happened.', + }, + UNAUTHORIZED: { + heading: 'Not authorized', + detail: 'This session is not permitted to read the intent.', + }, + TRANSPORT_UNAVAILABLE: { + heading: 'Service unavailable', + detail: + 'The OneShot API could not be reached. Nothing about the settlement state can be concluded from this.', + }, + }; + +/** + * Route slot for the B05 slice. + * + * Read-only: it fetches one intent and renders it. Failure states describe what + * is unknown rather than offering a retry that could imply a new settlement. + */ +export function SettlementDetailsRoute({ + businessIntentId, + client, + allowedExplorerHosts, +}: SettlementDetailsRouteProps) { + const [state, setState] = useState({ kind: 'LOADING' }); + + useEffect(() => { + let active = true; + setState({ kind: 'LOADING' }); + + const options: SettlementViewOptions = + allowedExplorerHosts === undefined ? {} : { allowedExplorerHosts }; + + void client + .readIntent(businessIntentId) + .then((intent) => { + if (!active) return; + setState({ kind: 'LOADED', view: toSettlementDetailsView(intent, options) }); + }) + .catch((error: unknown) => { + if (!active) return; + if (error instanceof SettlementClientError) { + const copy = FAILURE_COPY[error.failure]; + setState({ kind: 'FAILED', heading: copy.heading, detail: copy.detail }); + return; + } + if (error instanceof SanitizationError) { + setState({ + kind: 'FAILED', + heading: 'Response withheld', + detail: + 'The API response carried a field this interface refuses to render. Nothing was displayed.', + }); + return; + } + setState({ + kind: 'FAILED', + heading: 'Settlement details unavailable', + detail: 'The response could not be read. Authoritative state is unchanged.', + }); + }); + + return () => { + active = false; + }; + }, [businessIntentId, client, allowedExplorerHosts]); + + if (state.kind === 'LOADING') { + return ( +
+

ONESHOT / AUTHORIZATION AND SETTLEMENT

+

Loading settlement details…

+
+ ); + } + + if (state.kind === 'FAILED') { + return ( +
+

ONESHOT / AUTHORIZATION AND SETTLEMENT

+

{state.heading}

+

{state.detail}

+
+ ); + } + + return ; +} diff --git a/packages/settlement-ui/src/SettlementStatePanel.tsx b/packages/settlement-ui/src/SettlementStatePanel.tsx new file mode 100644 index 0000000..26faa3a --- /dev/null +++ b/packages/settlement-ui/src/SettlementStatePanel.tsx @@ -0,0 +1,113 @@ +import type { SettlementDetailsView, SettlementPhase } from './contract.js'; + +export interface SettlementStatePanelProps { + readonly view: SettlementDetailsView; +} + +const PHASE_LABELS: Readonly> = { + AWAITING_AUTHORIZATION: 'Awaiting authorization', + READY: 'Ready', + SUBMITTING: 'Submitting', + PENDING_UNKNOWN: 'Unknown', + COMMITTED: 'Committed', + FINAL_FAILED_SAFE: 'Failed safe', + REJECTED: 'Rejected', +}; + +const PHASE_TONE: Readonly> = { + AWAITING_AUTHORIZATION: 'pending', + READY: 'pending', + SUBMITTING: 'pending', + PENDING_UNKNOWN: 'warning', + COMMITTED: 'success', + FINAL_FAILED_SAFE: 'neutral', + REJECTED: 'danger', +}; + +const PHASE_EXPLANATIONS: Readonly> = { + AWAITING_AUTHORIZATION: 'No settlement may be submitted until authorization resolves.', + READY: 'Authorization passed and OneShot holds submission ownership for this intent.', + SUBMITTING: 'An attempt is crossing the provider boundary. Its outcome is not yet known.', + PENDING_UNKNOWN: + 'A payment may or may not have been broadcast. Reconciliation from durable, Privy, and Arc evidence resolves this; absence of an index result is not proof that no payment happened.', + COMMITTED: 'Exactly one settlement is committed for this Business Intent.', + FINAL_FAILED_SAFE: 'This intent closed without a committed settlement.', + REJECTED: 'Authorization rejected this intent, so no settlement exists.', +}; + +/** Arc is pending or final. Confirmation counts are deliberately not rendered. */ +function arcFinality(phase: SettlementPhase): string | null { + if (phase === 'COMMITTED') return 'Final'; + if (phase === 'SUBMITTING' || phase === 'PENDING_UNKNOWN') return 'Pending'; + return null; +} + +/** + * B05.3 settlement states. + * + * `UNKNOWN` renders as visibly non-terminal and carries no settlement action. + * The slice as a whole exposes no submit, resend, or force-pay control, so an + * ambiguous outcome has nothing to click. + */ +export function SettlementStatePanel({ view }: SettlementStatePanelProps) { + const finality = arcFinality(view.phase); + const unknown = view.phase === 'PENDING_UNKNOWN'; + + return ( +
+
+

Settlement

+ {PHASE_LABELS[view.phase]} +
+

{PHASE_EXPLANATIONS[view.phase]}

+ {unknown && ( +

+ Not final. No new settlement action is available for an unknown outcome. +

+ )} +
+
+
Durable state
+
{view.state}
+
+
+
Outcome
+
{view.terminal ? 'Final' : 'Not final'}
+
+ {finality !== null && ( +
+
Arc
+
{finality}
+
+ )} +
+ {!view.evidenceAvailable && ( +

+ No evidence observations are available for this intent yet. Missing evidence does not + change the authoritative state above. +

+ )} + {view.evidence.length > 0 && ( +
    + {view.evidence.map((entry) => ( +
  • + {entry.source} + + {entry.authorityClass} + + {entry.freshness !== null && {entry.freshness}} + + {entry.retrievedAt} + {entry.blockNumber !== null ? ` · block ${entry.blockNumber}` : ''} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/packages/settlement-ui/src/TransactionDetails.tsx b/packages/settlement-ui/src/TransactionDetails.tsx new file mode 100644 index 0000000..3e45d60 --- /dev/null +++ b/packages/settlement-ui/src/TransactionDetails.tsx @@ -0,0 +1,89 @@ +import type { SettlementDetailsView } from './contract.js'; + +export interface TransactionDetailsProps { + readonly view: SettlementDetailsView; +} + +/** + * B05.4 verified transaction details. + * + * The transaction block renders only when the projection marked the settlement + * verified: a committed durable state, a well-formed Arc identity, and + * authoritative Arc evidence. Anything less renders as unverified rather than + * presenting an unproven hash as settled fact. + */ +export function TransactionDetails({ view }: TransactionDetailsProps) { + const transaction = view.transaction; + + if (transaction === null) { + return ( +
+
+

Transaction

+ + {view.verification === 'UNVERIFIED' ? 'Unverified' : 'None recorded'} + +
+

+ {view.verification === 'UNVERIFIED' + ? 'A settlement record exists but is not yet bound to authoritative Arc evidence, so its details are withheld.' + : 'No settled transaction is recorded for this intent.'} +

+
+ ); + } + + return ( +
+
+

Transaction

+ Verified +
+
+
+
Transaction hash
+
{transaction.transactionHash}
+
+
+
Block
+
{transaction.blockNumber}
+
+
+
Token contract
+
{transaction.tokenContract ?? 'Not reported'}
+
+
+
Recipient
+
{transaction.recipient}
+
+
+
Amount
+
+ {transaction.amountDisplay ?? 'Malformed amount'}{' '} + {view.policy.asset} +
+
+
+
Transfer identity
+
log index {transaction.transferLogIndex}
+
+
+
Provider reference
+
{transaction.providerReferenceId}
+
+
+ {transaction.explorer.href !== null ? ( +

+ + View on the Arc explorer + +

+ ) : ( +

+ {transaction.explorer.rejectedReason ?? + 'No explorer link was published for this transaction.'} +

+ )} +
+ ); +} diff --git a/packages/settlement-ui/src/client.ts b/packages/settlement-ui/src/client.ts new file mode 100644 index 0000000..453186b --- /dev/null +++ b/packages/settlement-ui/src/client.ts @@ -0,0 +1,126 @@ +import { + createOpenApiMockFetch, + createDefaultMockState, + type IntentResponse, +} from '@oneshot/contracts'; + +import { assertNoSensitiveFields } from './contract.js'; + +export type SettlementClientFailure = + 'INTENT_NOT_FOUND' | 'EVIDENCE_UNAVAILABLE' | 'UNAUTHORIZED' | 'TRANSPORT_UNAVAILABLE'; + +export class SettlementClientError extends Error { + readonly failure: SettlementClientFailure; + + constructor(failure: SettlementClientFailure, message: string) { + super(message); + this.name = 'SettlementClientError'; + this.failure = failure; + } +} + +/** + * The read seam this slice needs. It is deliberately read-only: there is no + * submit, resend, or reconcile method, so no composition of this package can + * introduce a settlement action through the client. + */ +export interface SettlementClient { + readIntent(businessIntentId: string): Promise; +} + +export interface SettlementHttpClientOptions { + readonly baseUrl?: string; + readonly getAuthToken?: () => string | null; + readonly fetcher?: typeof fetch; +} + +function failureForStatus(status: number): SettlementClientFailure { + if (status === 404) return 'INTENT_NOT_FOUND'; + if (status === 401 || status === 403) return 'UNAUTHORIZED'; + if (status === 503) return 'EVIDENCE_UNAVAILABLE'; + return 'TRANSPORT_UNAVAILABLE'; +} + +/** + * HTTP client for the frozen OpenAPI seam. It is used against the real API and + * against the frozen mock server without change, because both serve the same + * contract. + */ +export function createSettlementClient( + options: SettlementHttpClientOptions = {}, +): SettlementClient { + const baseUrl = (options.baseUrl ?? '').replace(/\/+$/u, ''); + const fetcher = options.fetcher ?? globalThis.fetch; + + return { + async readIntent(businessIntentId: string): Promise { + const token = options.getAuthToken?.() ?? null; + const headers: Record = { accept: 'application/json' }; + if (token !== null && token !== '') { + headers.authorization = `Bearer ${token}`; + } + + let response: Response; + try { + response = await fetcher(`${baseUrl}/v1/intents/${encodeURIComponent(businessIntentId)}`, { + headers, + }); + } catch { + throw new SettlementClientError( + 'TRANSPORT_UNAVAILABLE', + 'The OneShot API could not be reached.', + ); + } + + if (!response.ok) { + throw new SettlementClientError( + failureForStatus(response.status), + `The OneShot API returned status ${response.status}.`, + ); + } + + const payload: unknown = await response.json(); + assertNoSensitiveFields(payload); + return payload as IntentResponse; + }, + }; +} + +/** Client bound to the frozen mock server published by `@oneshot/contracts`. */ +export function createMockSettlementClient( + options: { readonly baseUrl?: string } = {}, +): SettlementClient { + const fetcher = createOpenApiMockFetch(createDefaultMockState()); + const httpOptions: SettlementHttpClientOptions = { + baseUrl: options.baseUrl ?? 'http://mock.local', + fetcher, + }; + return createSettlementClient(httpOptions); +} + +/** + * Deterministic client for component tests. It answers from an in-memory map + * and needs no network, timers, or mock-server routing. + */ +export function createInMemorySettlementClient( + intents: Readonly>, + failures: Readonly> = {}, +): SettlementClient { + return { + readIntent(businessIntentId: string): Promise { + const failure = failures[businessIntentId]; + if (failure !== undefined) { + return Promise.reject( + new SettlementClientError(failure, `Fixture client failure: ${failure}`), + ); + } + const intent = intents[businessIntentId]; + if (intent === undefined) { + return Promise.reject( + new SettlementClientError('INTENT_NOT_FOUND', 'No fixture for this Business Intent.'), + ); + } + return Promise.resolve(intent); + }, + }; +} diff --git a/packages/settlement-ui/src/contract.ts b/packages/settlement-ui/src/contract.ts new file mode 100644 index 0000000..50f1aed --- /dev/null +++ b/packages/settlement-ui/src/contract.ts @@ -0,0 +1,449 @@ +import { + OPENAPI_MOCK_SERVER_VERSION, + type AttemptView, + type AuthorizationStatus, + type EvidenceView, + type IntentResponse, + type IntentState, + type PolicyStatus, +} from '@oneshot/contracts'; + +import { compareAtomic, formatAtomicUsdc, isAtomicAmount } from './money.js'; + +export const SETTLEMENT_UI_CONTRACT_VERSION = 'settlement-details-v1' as const; + +/** Mock-server version this slice is built and tested against. */ +export const SETTLEMENT_UI_MOCK_SERVER_VERSION = OPENAPI_MOCK_SERVER_VERSION; + +const MAX_TEXT_LENGTH = 256; +const MAX_EXPLORER_URL_LENGTH = 256; +const EVM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/u; +const TRANSACTION_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/u; +const BLOCK_NUMBER_PATTERN = /^(0|[1-9][0-9]*)$/u; + +/** + * Control characters are matched by code point rather than by a regular + * expression: a control character inside a pattern literal is exactly the kind + * of invisible source that `no-control-regex` exists to prevent. + */ +function isControlCodePoint(codePoint: number): boolean { + return codePoint < 0x20 || codePoint === 0x7f; +} + +function containsControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (isControlCodePoint(value.charCodeAt(index))) { + return true; + } + } + return false; +} + +function replaceControlCharacters(value: string, replacement: string): string { + let result = ''; + for (const character of value) { + result += isControlCodePoint(character.codePointAt(0) ?? 0) ? replacement : character; + } + return result; +} + +/** + * Field names that must never reach a component prop. The API is expected to be + * sanitized upstream; this is the consumer-side fail-closed check, because a + * future provider field leaking through would otherwise render. + */ +const FORBIDDEN_KEY_FRAGMENTS = [ + 'secret', + 'private', + 'seed', + 'mnemonic', + 'passphrase', + 'password', + 'credential', + 'signature', + 'signed', + 'apikey', + 'api_key', + 'access_token', + 'bearer', + 'authorization_header', + 'raw_policy', + 'raw_response', + 'raw_provider', +] as const; + +export class SanitizationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SanitizationError'; + } +} + +/** + * Rejects a payload carrying a field whose name indicates secret or raw + * provider material. Runs before projection, so an unexpected shape fails the + * render instead of being partially trusted. + */ +export function assertNoSensitiveFields(value: unknown, path = '$'): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => { + assertNoSensitiveFields(entry, `${path}[${index}]`); + }); + return; + } + if (value === null || typeof value !== 'object') { + return; + } + for (const [key, entry] of Object.entries(value as Record)) { + const normalized = key.toLowerCase(); + const forbidden = FORBIDDEN_KEY_FRAGMENTS.find((fragment) => normalized.includes(fragment)); + if (forbidden !== undefined) { + throw new SanitizationError( + `Refusing to render ${path}.${key}: field name matches forbidden fragment "${forbidden}"`, + ); + } + assertNoSensitiveFields(entry, `${path}.${key}`); + } +} + +/** Strips control characters and bounds length for any operator-facing string. */ +export function sanitizeText(value: string | undefined | null): string | null { + if (typeof value !== 'string') { + return null; + } + const stripped = replaceControlCharacters(value, ' ').trim(); + if (stripped.length === 0) { + return null; + } + return stripped.length > MAX_TEXT_LENGTH + ? `${stripped.slice(0, MAX_TEXT_LENGTH - 1)}…` + : stripped; +} + +export interface ExplorerLink { + readonly href: string | null; + readonly rejectedReason: string | null; +} + +/** + * Arc testnet explorer host this repository documents in + * `docs/settlement/PROVIDER_SETUP.md`. Deployments that publish links through a + * different explorer pass their own host list rather than widening this one. + */ +export const DEFAULT_EXPLORER_HOSTS: readonly string[] = ['testnet.arcscan.app']; + +/** + * Validates an outbound explorer URL before it can become an anchor href. + * + * The OpenAPI field is a bounded string with no scheme constraint, so the rules + * live here: https only, no embedded credentials, a host on the allowlist, and + * a reference to the exact transaction hash being displayed. Hash binding alone + * is not enough, because a hostile host can quote the real hash back; the + * allowlist is what keeps a spoofed response from producing a clickable link. + * A link that fails any rule is dropped rather than rendered. + */ +export function validateExplorerUrl( + rawUrl: string | undefined | null, + transactionHash: string, + allowedHosts: readonly string[] = DEFAULT_EXPLORER_HOSTS, +): ExplorerLink { + if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) { + return { href: null, rejectedReason: null }; + } + const candidate = rawUrl.trim(); + if (candidate.length > MAX_EXPLORER_URL_LENGTH) { + return { href: null, rejectedReason: 'Explorer link exceeds the permitted length.' }; + } + if (containsControlCharacter(candidate) || /\s/u.test(candidate)) { + return { href: null, rejectedReason: 'Explorer link contains unsupported characters.' }; + } + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { href: null, rejectedReason: 'Explorer link is not a valid absolute URL.' }; + } + if (parsed.protocol !== 'https:') { + return { href: null, rejectedReason: 'Explorer link must use https.' }; + } + if (parsed.username !== '' || parsed.password !== '') { + return { href: null, rejectedReason: 'Explorer link must not embed credentials.' }; + } + if (parsed.hostname === '') { + return { href: null, rejectedReason: 'Explorer link has no host.' }; + } + const host = parsed.hostname.toLowerCase(); + if (!allowedHosts.some((allowed) => allowed.toLowerCase() === host)) { + return { href: null, rejectedReason: 'Explorer host is not on the allowlist.' }; + } + if (!TRANSACTION_HASH_PATTERN.test(transactionHash)) { + return { href: null, rejectedReason: 'Transaction hash is not a valid Arc transaction hash.' }; + } + const reference = `${parsed.pathname}${parsed.search}`.toLowerCase(); + if (!reference.includes(transactionHash.toLowerCase())) { + return { + href: null, + rejectedReason: 'Explorer link does not reference this transaction hash.', + }; + } + return { href: parsed.toString(), rejectedReason: null }; +} + +export type SettlementPhase = + | 'AWAITING_AUTHORIZATION' + | 'READY' + | 'SUBMITTING' + | 'PENDING_UNKNOWN' + | 'COMMITTED' + | 'FINAL_FAILED_SAFE' + | 'REJECTED'; + +export type VerificationState = 'VERIFIED' | 'UNVERIFIED' | 'NONE'; + +export type AuthorizationDisplayStatus = AuthorizationStatus | 'NOT_REPORTED'; + +export type PolicyDisplayStatus = PolicyStatus | 'NOT_REPORTED'; + +export interface PolicySummaryDisplay { + readonly policyId: string | null; + readonly status: PolicyDisplayStatus; + readonly network: string; + readonly asset: string; + readonly recipient: string; + readonly allowedRecipients: readonly string[]; + /** `null` when no allowlist is reported: unknown is not the same as allowed. */ + readonly recipientAllowlisted: boolean | null; + readonly settlementCapAtomic: string | null; + readonly settlementCapDisplay: string | null; + readonly amountAtomic: string; + readonly amountDisplay: string | null; + /** `null` when either amount or cap is missing or malformed. */ + readonly amountWithinCap: boolean | null; +} + +export interface AuthorizationDisplay { + readonly status: AuthorizationDisplayStatus; + readonly attemptId: string | null; + readonly occurredAt: string | null; + readonly sanitizedReason: string | null; + readonly terminal: boolean; +} + +export interface VerifiedTransactionDisplay { + readonly providerReferenceId: string; + readonly transactionHash: string; + readonly blockNumber: string; + readonly transferLogIndex: number; + readonly tokenContract: string | null; + readonly recipient: string; + readonly amountAtomic: string; + readonly amountDisplay: string | null; + readonly explorer: ExplorerLink; +} + +export interface EvidenceDisplay { + readonly source: EvidenceView['source']; + readonly authorityClass: EvidenceView['authority_class']; + readonly retrievedAt: string; + readonly digest: string; + readonly blockNumber: string | null; + readonly freshness: NonNullable | null; +} + +export interface SettlementDetailsView { + readonly contractVersion: typeof SETTLEMENT_UI_CONTRACT_VERSION; + readonly businessIntentId: string; + readonly purpose: string | null; + readonly state: IntentState; + readonly phase: SettlementPhase; + /** `false` for `UNKNOWN`: an ambiguous outcome is never a finished one. */ + readonly terminal: boolean; + readonly policy: PolicySummaryDisplay; + readonly authorization: AuthorizationDisplay; + readonly verification: VerificationState; + readonly transaction: VerifiedTransactionDisplay | null; + readonly evidence: readonly EvidenceDisplay[]; + readonly evidenceAvailable: boolean; +} + +const PHASE_BY_STATE: Readonly> = { + AUTHORIZING: 'AWAITING_AUTHORIZATION', + READY: 'READY', + SUBMITTING: 'SUBMITTING', + UNKNOWN: 'PENDING_UNKNOWN', + COMMITTED: 'COMMITTED', + FAILED_SAFE: 'FINAL_FAILED_SAFE', + REJECTED: 'REJECTED', +}; + +const TERMINAL_PHASES: ReadonlySet = new Set([ + 'COMMITTED', + 'FINAL_FAILED_SAFE', + 'REJECTED', +]); + +const TERMINAL_AUTHORIZATION: ReadonlySet = + new Set(['DENIED', 'CONFIG_MISMATCH']); + +function projectEvidence(evidence: readonly EvidenceView[]): readonly EvidenceDisplay[] { + return evidence.map((entry) => ({ + source: entry.source, + authorityClass: entry.authority_class, + retrievedAt: entry.retrieved_at, + digest: entry.digest, + blockNumber: + typeof entry.block_number === 'string' && BLOCK_NUMBER_PATTERN.test(entry.block_number) + ? entry.block_number + : null, + freshness: entry.freshness ?? null, + })); +} + +function hasAuthoritativeArcEvidence(evidence: readonly EvidenceView[]): boolean { + return evidence.some( + (entry) => entry.source === 'ARC' && entry.authority_class === 'AUTHORITATIVE', + ); +} + +/** + * Selects the attempt whose authorization status is displayed. + * + * The contract does not promise that `attempts` is ordered, so recency comes + * from `created_at` rather than array position. An equal timestamp keeps the + * later element, and an attempt with an unparsable timestamp never displaces + * one that carries a usable date. + */ +function latestAttempt(attempts: readonly AttemptView[]): AttemptView | null { + let selected: AttemptView | null = null; + let selectedTime = Number.NEGATIVE_INFINITY; + + for (const attempt of attempts) { + const time = Date.parse(attempt.created_at); + if (Number.isNaN(time)) { + selected ??= attempt; + continue; + } + if (selected === null || time >= selectedTime) { + selected = attempt; + selectedTime = time; + } + } + + return selected; +} + +export interface SettlementViewOptions { + /** Explorer hosts a link may point at. Defaults to `DEFAULT_EXPLORER_HOSTS`. */ + readonly allowedExplorerHosts?: readonly string[]; +} + +/** + * Projects one frozen `IntentResponse` into the display model. + * + * Only known contract fields are copied, so an added provider field cannot + * reach a component prop by accident. Malformed identity fields collapse to + * `null` instead of rendering an unverified value as fact. + */ +export function toSettlementDetailsView( + intent: IntentResponse, + options: SettlementViewOptions = {}, +): SettlementDetailsView { + assertNoSensitiveFields(intent); + + const state = intent.state; + const phase = PHASE_BY_STATE[state]; + const amountAtomic = intent.amount_atomic; + const capAtomic = + typeof intent.policy?.settlement_cap_atomic === 'string' && + isAtomicAmount(intent.policy.settlement_cap_atomic) + ? intent.policy.settlement_cap_atomic + : null; + const allowedRecipients = (intent.policy?.allowed_recipients ?? []).filter((entry) => + EVM_ADDRESS_PATTERN.test(entry), + ); + const capComparison = capAtomic === null ? null : compareAtomic(amountAtomic, capAtomic); + + const displayedAttempt = latestAttempt(intent.attempts); + const authorizationStatus: AuthorizationDisplayStatus = + displayedAttempt?.authorization_status ?? 'NOT_REPORTED'; + + const settlement = intent.settlement ?? null; + const settlementIsWellFormed = + settlement !== null && + TRANSACTION_HASH_PATTERN.test(settlement.transaction_hash) && + BLOCK_NUMBER_PATTERN.test(settlement.block_number) && + Number.isInteger(settlement.transfer_log_index) && + settlement.transfer_log_index >= 0; + + const verified = + settlement !== null && + settlementIsWellFormed && + state === 'COMMITTED' && + hasAuthoritativeArcEvidence(intent.evidence); + + const verification: VerificationState = + settlement === null ? 'NONE' : verified ? 'VERIFIED' : 'UNVERIFIED'; + + const transaction: VerifiedTransactionDisplay | null = + settlement !== null && verified + ? { + providerReferenceId: settlement.provider_reference_id, + transactionHash: settlement.transaction_hash, + blockNumber: settlement.block_number, + transferLogIndex: settlement.transfer_log_index, + tokenContract: + typeof settlement.token_contract === 'string' && + EVM_ADDRESS_PATTERN.test(settlement.token_contract) + ? settlement.token_contract + : null, + recipient: intent.recipient, + amountAtomic, + amountDisplay: formatAtomicUsdc(amountAtomic), + explorer: validateExplorerUrl( + settlement.explorer_url, + settlement.transaction_hash, + options.allowedExplorerHosts ?? DEFAULT_EXPLORER_HOSTS, + ), + } + : null; + + return { + contractVersion: SETTLEMENT_UI_CONTRACT_VERSION, + businessIntentId: intent.business_intent_id, + purpose: sanitizeText(intent.purpose), + state, + phase, + terminal: TERMINAL_PHASES.has(phase), + policy: { + policyId: sanitizeText(intent.policy?.policy_id), + status: intent.policy?.status ?? 'NOT_REPORTED', + network: intent.network, + asset: intent.asset, + recipient: intent.recipient, + allowedRecipients, + recipientAllowlisted: + allowedRecipients.length === 0 + ? null + : allowedRecipients.some( + (entry) => entry.toLowerCase() === intent.recipient.toLowerCase(), + ), + settlementCapAtomic: capAtomic, + settlementCapDisplay: capAtomic === null ? null : formatAtomicUsdc(capAtomic), + amountAtomic, + amountDisplay: formatAtomicUsdc(amountAtomic), + amountWithinCap: capComparison === null ? null : capComparison <= 0, + }, + authorization: { + status: authorizationStatus, + attemptId: displayedAttempt?.attempt_id ?? null, + occurredAt: displayedAttempt?.created_at ?? null, + sanitizedReason: sanitizeText(displayedAttempt?.sanitized_error), + terminal: TERMINAL_AUTHORIZATION.has(authorizationStatus), + }, + verification, + transaction, + evidence: projectEvidence(intent.evidence), + evidenceAvailable: intent.evidence.length > 0, + }; +} diff --git a/packages/settlement-ui/src/fixtures.ts b/packages/settlement-ui/src/fixtures.ts new file mode 100644 index 0000000..c47d8bf --- /dev/null +++ b/packages/settlement-ui/src/fixtures.ts @@ -0,0 +1,244 @@ +import { UI_FIXTURES, type IntentResponse } from '@oneshot/contracts'; + +export type ScenarioSource = 'FROZEN_CONTRACT_PACK' | 'LANE_B_LOCAL'; + +export interface SettlementScenario { + readonly scenario: string; + readonly description: string; + readonly source: ScenarioSource; + readonly intent: IntentResponse; +} + +const RECIPIENT = '0x1111111111111111111111111111111111111111'; +const TOKEN_CONTRACT = '0x3600000000000000000000000000000000000000'; +const REVERT_HASH = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const HOSTILE_HASH = '0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +/** + * Host the synthetic fixtures publish links through. It is not the documented + * Arc testnet explorer, so a viewer rendering these fixtures must opt into it + * explicitly rather than the package widening `DEFAULT_EXPLORER_HOSTS`. + */ +export const FIXTURE_EXPLORER_HOSTS: readonly string[] = ['testnet.arcscan.io']; + +const BASE_POLICY = { + policy_id: 'privy-policy-arc-prod', + status: 'CONFIGURED', + settlement_cap_atomic: '10000000', + allowed_recipients: [RECIPIENT], +} as const; + +function baseIntent( + overrides: Partial & { readonly state: IntentResponse['state'] }, +): IntentResponse { + return { + business_intent_id: 'placeholder', + payload_fingerprint: 'b000000000000000000000000000000000000000000000000000000000000001', + recipient: RECIPIENT, + amount_atomic: '1250000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Invoice INV-1001', + version: 1, + policy: BASE_POLICY, + attempts: [], + evidence: [], + ...overrides, + }; +} + +/** + * States B05.3 requires that the frozen contract pack does not carry. + * + * Published contract fixture digests are immutable, so these live in the Lane B + * package instead of being added to `@oneshot/contracts`. They are built from + * the same frozen schema and carry no secret or wallet material. + */ +const LOCAL_SCENARIOS: readonly SettlementScenario[] = [ + { + scenario: 'ready-authorized', + description: 'Authorized and holding submission ownership, with nothing submitted yet', + source: 'LANE_B_LOCAL', + intent: baseIntent({ + business_intent_id: '018f-ui-ready-101', + state: 'READY', + version: 2, + attempts: [ + { + attempt_id: 'attempt-ui-101', + stage: 'READY', + created_at: '2026-09-08T13:00:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + evidence: [ + { + source: 'PRIVY', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T13:00:01.000Z', + digest: 'digest-ready-101', + }, + ], + }), + }, + { + scenario: 'submitting-in-flight', + description: 'An attempt is crossing the provider boundary with no known outcome', + source: 'LANE_B_LOCAL', + intent: baseIntent({ + business_intent_id: '018f-ui-submitting-102', + state: 'SUBMITTING', + version: 3, + attempts: [ + { + attempt_id: 'attempt-ui-102', + stage: 'SUBMITTING', + created_at: '2026-09-08T13:05:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + evidence: [ + { + source: 'ONESHOT', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T13:05:01.000Z', + digest: 'digest-submitting-102', + }, + ], + }), + }, + { + scenario: 'final-revert', + description: 'Broadcast transaction reverted on Arc and closed without a committed settlement', + source: 'LANE_B_LOCAL', + intent: baseIntent({ + business_intent_id: '018f-ui-revert-103', + state: 'FAILED_SAFE', + version: 4, + attempts: [ + { + attempt_id: 'attempt-ui-103', + stage: 'FAILED_SAFE', + created_at: '2026-09-08T13:10:00.000Z', + sanitized_error: 'Arc receipt status reverted; no ERC-20 Transfer was emitted', + authorization_status: 'AUTHORIZED', + }, + ], + settlement: { + provider_reference_id: 'arc-tx-103', + transaction_hash: REVERT_HASH, + block_number: '210', + transfer_log_index: 0, + token_contract: TOKEN_CONTRACT, + explorer_url: `https://testnet.arcscan.io/tx/${REVERT_HASH}`, + }, + evidence: [ + { + source: 'ARC', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T13:10:05.000Z', + digest: 'digest-revert-103', + block_number: '210', + }, + ], + }), + }, + { + scenario: 'hostile-explorer-link', + description: 'Committed settlement whose published explorer link fails outbound validation', + source: 'LANE_B_LOCAL', + intent: baseIntent({ + business_intent_id: '018f-ui-hostile-104', + state: 'COMMITTED', + version: 4, + purpose: 'Invoice ', + attempts: [ + { + attempt_id: 'attempt-ui-104', + stage: 'COMMITTED', + created_at: '2026-09-08T13:15:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + settlement: { + provider_reference_id: 'arc-tx-104', + transaction_hash: HOSTILE_HASH, + block_number: '220', + transfer_log_index: 1, + token_contract: TOKEN_CONTRACT, + explorer_url: 'javascript:alert(document.domain)', + }, + evidence: [ + { + source: 'ARC', + authority_class: 'AUTHORITATIVE', + retrieved_at: '2026-09-08T13:15:05.000Z', + digest: 'digest-hostile-104', + block_number: '220', + }, + ], + }), + }, + { + scenario: 'committed-without-arc-evidence', + description: 'Committed record with no authoritative Arc observation, so details stay withheld', + source: 'LANE_B_LOCAL', + intent: baseIntent({ + business_intent_id: '018f-ui-unverified-105', + state: 'COMMITTED', + version: 4, + attempts: [ + { + attempt_id: 'attempt-ui-105', + stage: 'COMMITTED', + created_at: '2026-09-08T13:20:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + settlement: { + provider_reference_id: 'arc-tx-105', + transaction_hash: '0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + block_number: '230', + transfer_log_index: 0, + }, + evidence: [ + { + source: 'THE_GRAPH', + authority_class: 'OBSERVATION', + retrieved_at: '2026-09-08T13:20:05.000Z', + digest: 'digest-unverified-105', + freshness: 'FRESH', + }, + ], + }), + }, +]; + +const FROZEN_SCENARIOS: readonly SettlementScenario[] = Object.values(UI_FIXTURES).map( + (fixture) => ({ + scenario: fixture.scenario, + description: fixture.description, + source: 'FROZEN_CONTRACT_PACK' as const, + intent: fixture.intent, + }), +); + +export const SETTLEMENT_SCENARIOS: Readonly> = Object.freeze( + Object.fromEntries( + [...FROZEN_SCENARIOS, ...LOCAL_SCENARIOS].map((scenario) => [scenario.scenario, scenario]), + ), +); + +/** Business Intent id to intent, for `createInMemorySettlementClient`. */ +export const SETTLEMENT_SCENARIO_INTENTS: Readonly> = Object.freeze( + Object.fromEntries( + Object.values(SETTLEMENT_SCENARIOS).map((scenario) => [ + scenario.intent.business_intent_id, + scenario.intent, + ]), + ), +); + +export function scenarioNames(): readonly string[] { + return Object.keys(SETTLEMENT_SCENARIOS); +} diff --git a/packages/settlement-ui/src/index.ts b/packages/settlement-ui/src/index.ts new file mode 100644 index 0000000..1b3ecb0 --- /dev/null +++ b/packages/settlement-ui/src/index.ts @@ -0,0 +1,13 @@ +import './styles.css'; + +export * from './AuthorizationStatePanel.js'; +export * from './client.js'; +export * from './contract.js'; +export * from './DemoShell.js'; +export * from './fixtures.js'; +export * from './money.js'; +export * from './PolicySummaryPanel.js'; +export * from './SettlementDetails.js'; +export * from './SettlementDetailsRoute.js'; +export * from './SettlementStatePanel.js'; +export * from './TransactionDetails.js'; diff --git a/packages/settlement-ui/src/main.tsx b/packages/settlement-ui/src/main.tsx new file mode 100644 index 0000000..03ac80c --- /dev/null +++ b/packages/settlement-ui/src/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { DemoShell } from './DemoShell.js'; +import { SETTLEMENT_SCENARIOS } from './fixtures.js'; +import './styles.css'; + +const params = new URLSearchParams(window.location.search); +const requested = params.get('scenario'); +const scenario = + requested !== null && Object.hasOwn(SETTLEMENT_SCENARIOS, requested) + ? requested + : 'authorized-committed'; +const root = document.querySelector('#root'); + +if (!(root instanceof HTMLElement)) throw new Error('Missing settlement UI root'); + +createRoot(root).render( + + + , +); diff --git a/packages/settlement-ui/src/money.ts b/packages/settlement-ui/src/money.ts new file mode 100644 index 0000000..b8a812b --- /dev/null +++ b/packages/settlement-ui/src/money.ts @@ -0,0 +1,54 @@ +/** + * Exact USDC rendering for the settlement slice. + * + * Money crosses the API seam as a canonical base-10 integer string of atomic + * units. It is formatted here with `bigint` string arithmetic only: no + * `Number`, `parseFloat`, or scaling by `1e6`, because a floating-point step + * would silently round a settled amount. + */ + +export const USDC_DECIMALS = 6; + +const USDC_SCALE = 1_000_000n; +const ATOMIC_PATTERN = /^(0|[1-9][0-9]*)$/u; + +/** True when `value` is the canonical unsigned integer string the contract requires. */ +export function isAtomicAmount(value: string): boolean { + return ATOMIC_PATTERN.test(value); +} + +/** + * Formats atomic units as an exact USDC decimal string with all six places. + * Returns `null` for anything that is not a canonical atomic amount, so a + * malformed field renders as unavailable instead of as a plausible number. + */ +export function formatAtomicUsdc(value: string): string | null { + if (!isAtomicAmount(value)) { + return null; + } + const atomic = BigInt(value); + const whole = atomic / USDC_SCALE; + const fraction = atomic % USDC_SCALE; + return `${whole.toString()}.${fraction.toString().padStart(USDC_DECIMALS, '0')}`; +} + +/** Formats atomic units as an exact amount with its asset symbol, or `null`. */ +export function formatAtomicUsdcWithAsset(value: string, asset: string): string | null { + const formatted = formatAtomicUsdc(value); + return formatted === null ? null : `${formatted} ${asset}`; +} + +/** + * Compares two atomic amounts. Returns `null` when either side is malformed so + * callers fail closed rather than treating an unparsable cap as satisfied. + */ +export function compareAtomic(left: string, right: string): -1 | 0 | 1 | null { + if (!isAtomicAmount(left) || !isAtomicAmount(right)) { + return null; + } + const a = BigInt(left); + const b = BigInt(right); + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/settlement-ui/src/styles.css b/packages/settlement-ui/src/styles.css new file mode 100644 index 0000000..db9750f --- /dev/null +++ b/packages/settlement-ui/src/styles.css @@ -0,0 +1,303 @@ +.settlement-details, +.route-state { + --ink: #e8eef7; + --muted: #9db0c7; + --line: #203047; + --panel: #0d1a2c; + --green: #7ce7b4; + --amber: #ffc65c; + --red: #ff9a9a; + --cyan: #7ee6f2; + color: var(--ink); + font-family: Manrope, Inter, ui-sans-serif, system-ui, sans-serif; + display: flex; + flex-direction: column; + gap: 16px; + padding: clamp(16px, 3vw, 32px); + background: #07101d; +} + +.settlement-details *, +.route-state * { + box-sizing: border-box; +} + +.details-header h1 { + margin: 4px 0 0; + font-size: clamp(1.1rem, 2.4vw, 1.5rem); + line-height: 1.3; +} + +.eyebrow { + margin: 0; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.purpose { + margin: 8px 0 0; + color: var(--muted); +} + +.panel { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + padding: clamp(14px, 2vw, 22px); +} + +.panel-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.panel-heading h2 { + margin: 0; + font-size: 1rem; + letter-spacing: 0.02em; +} + +.panel-lede { + margin: 0 0 12px; + color: var(--muted); + line-height: 1.5; +} + +.panel-note { + margin: 12px 0 0; + padding: 10px 12px; + border-left: 3px solid var(--amber); + background: #1a2437; + color: var(--ink); + line-height: 1.5; +} + +.sanitized-reason { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px dashed var(--line); + border-radius: 8px; + line-height: 1.5; +} + +.sanitized-reason .eyebrow { + display: block; + margin-bottom: 4px; +} + +.badge { + border: 1px solid currentcolor; + border-radius: 999px; + padding: 3px 10px; + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.tone-success { + color: var(--green); +} + +.tone-warning, +.tone-pending { + color: var(--amber); +} + +.tone-danger { + color: var(--red); +} + +.tone-neutral { + color: var(--muted); +} + +.facts { + display: grid; + gap: 10px 24px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin: 0; +} + +.facts > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.facts dt { + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.facts dd { + margin: 0; + min-width: 0; +} + +.fact-note { + display: block; + color: var(--muted); + font-size: 0.8rem; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; +} + +.break-all { + overflow-wrap: anywhere; +} + +.inline-ok { + color: var(--green); + font-size: 0.8rem; +} + +.inline-warning { + color: var(--amber); + font-size: 0.8rem; +} + +.address-list { + margin: 0; + padding-left: 18px; +} + +.evidence-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 14px 0 0; + padding: 0; + list-style: none; +} + +.evidence-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.evidence-source { + font-weight: 600; + letter-spacing: 0.04em; +} + +.chip { + border: 1px solid var(--line); + border-radius: 6px; + padding: 2px 8px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.06em; +} + +.chip-authoritative { + color: var(--green); +} + +.chip-observation { + color: var(--cyan); +} + +.chip-advisory { + color: var(--amber); +} + +.evidence-meta { + color: var(--muted); + font-size: 0.8rem; +} + +.explorer-link { + margin: 14px 0 0; +} + +.explorer-link a { + color: var(--cyan); +} + +.explorer-link a:focus-visible, +.demo-scenarios select:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; +} + +.route-state h1 { + margin: 4px 0 8px; + font-size: 1.2rem; +} + +.route-state p { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.demo-bar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #f0c36a59; + background: #241b0d; + color: #f8e7bd; + padding: 12px clamp(16px, 3vw, 32px); + font-family: Manrope, Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 0.8rem; +} + +.demo-scenarios { + display: flex; + align-items: center; + gap: 8px; +} + +.demo-scenarios select { + border: 1px solid #f0c36a59; + border-radius: 6px; + background: #120d05; + color: #f8e7bd; + padding: 4px 8px; +} + +@media (max-width: 860px) { + .facts { + grid-template-columns: 1fr; + } + + .panel-heading { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 620px) { + .settlement-details, + .route-state { + padding: 14px; + } + + .demo-bar { + flex-direction: column; + align-items: flex-start; + } + + .evidence-item { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/packages/settlement-ui/src/vite-env.d.ts b/packages/settlement-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/settlement-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/settlement-ui/test/component.test.ts b/packages/settlement-ui/test/component.test.ts new file mode 100644 index 0000000..11c7091 --- /dev/null +++ b/packages/settlement-ui/test/component.test.ts @@ -0,0 +1,290 @@ +// @vitest-environment jsdom + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { cleanup, render, screen, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import axe from 'axe-core'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { toSettlementDetailsView } from '../src/contract.js'; +import { FIXTURE_EXPLORER_HOSTS, SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; +import { SettlementDetailsPanel } from '../src/SettlementDetails.js'; + +afterEach(cleanup); + +const SCENARIOS = Object.values(SETTLEMENT_SCENARIOS); + +function renderScenario(name: string) { + const scenario = SETTLEMENT_SCENARIOS[name]; + if (scenario === undefined) throw new Error(`Missing fixture: ${name}`); + return render( + createElement(SettlementDetailsPanel, { + view: toSettlementDetailsView(scenario.intent, { + allowedExplorerHosts: FIXTURE_EXPLORER_HOSTS, + }), + }), + ); +} + +function packageRoot(): string { + return process.cwd().endsWith('settlement-ui') + ? process.cwd() + : join(process.cwd(), 'packages', 'settlement-ui'); +} + +describe('every published scenario', () => { + it.each(SCENARIOS.map((scenario) => scenario.scenario))('renders %s', (name) => { + const { container } = renderScenario(name); + expect(screen.getByRole('heading', { level: 2, name: 'Policy' })).toBeTruthy(); + expect(screen.getByRole('heading', { level: 2, name: 'Authorization' })).toBeTruthy(); + expect(screen.getByRole('heading', { level: 2, name: 'Settlement' })).toBeTruthy(); + expect(screen.getByRole('heading', { level: 2, name: 'Transaction' })).toBeTruthy(); + expect(container.querySelector('.settlement-details')).not.toBeNull(); + }); + + it.each(SCENARIOS.map((scenario) => scenario.scenario))( + 'offers no settlement action in %s', + (name) => { + const { container } = renderScenario(name); + expect(container.querySelectorAll('button')).toHaveLength(0); + expect(container.querySelectorAll('form')).toHaveLength(0); + expect(container.querySelectorAll('input')).toHaveLength(0); + const text = container.textContent ?? ''; + for (const forbidden of ['Retry', 'Resend', 'Force', 'Submit again', 'Pay again']) { + expect(text).not.toContain(forbidden); + } + }, + ); + + it.each(SCENARIOS.map((scenario) => scenario.scenario))( + 'never renders a confirmation count in %s', + (name) => { + const { container } = renderScenario(name); + expect(container.textContent ?? '').not.toMatch(/confirmation/iu); + }, + ); + + it.each(SCENARIOS.map((scenario) => scenario.scenario))( + 'passes an accessibility scan for %s', + async (name) => { + const { container } = renderScenario(name); + const results = await axe.run(container, { + rules: { 'color-contrast': { enabled: false } }, + }); + expect(results.violations).toEqual([]); + }, + ); +}); + +describe('policy summary', () => { + it('renders the sanitized policy facts', () => { + renderScenario('authorized-committed'); + const policy = screen.getByRole('region', { name: 'Policy' }); + expect(within(policy).getByText('eip155:5042002')).toBeTruthy(); + expect(within(policy).getByText('Configured')).toBeTruthy(); + expect(within(policy).getByText('10.000000 USDC')).toBeTruthy(); + expect(within(policy).getByText('On the allowlist')).toBeTruthy(); + expect(within(policy).getByText('At or under the cap')).toBeTruthy(); + }); + + it('shows an above-cap amount against the reported cap', () => { + renderScenario('auth-cap-exceeded'); + const policy = screen.getByRole('region', { name: 'Policy' }); + expect(within(policy).getByText('Cap exceeded')).toBeTruthy(); + expect(within(policy).getByText('Above the cap')).toBeTruthy(); + expect(within(policy).getByText('50.000000')).toBeTruthy(); + }); + + it('does not claim allowlist membership when no allowlist is reported', () => { + renderScenario('auth-config-mismatch'); + const policy = screen.getByRole('region', { name: 'Policy' }); + expect(within(policy).getByText('No allowlist reported')).toBeTruthy(); + }); +}); + +describe('authorization states', () => { + it.each([ + ['auth-checking', 'Checking'], + ['authorized-committed', 'Authorized'], + ['auth-denied-recipient', 'Denied'], + ['auth-unavailable', 'Unavailable'], + ['auth-config-mismatch', 'Configuration mismatch'], + ])('labels %s as %s', (name, label) => { + renderScenario(name); + const panel = screen.getByRole('region', { name: 'Authorization' }); + expect(within(panel).getByText(label)).toBeTruthy(); + }); + + it('explains a denial without offering a way around it', () => { + renderScenario('auth-denied-recipient'); + const panel = screen.getByRole('region', { name: 'Authorization' }); + expect( + within(panel).getByText(/Privy refused this attempt\. No settlement was submitted/u), + ).toBeTruthy(); + expect(within(panel).getByText(/this interface offers no bypass/u)).toBeTruthy(); + expect( + within(panel).getByText(/Recipient 0x1111111111111111111111111111111111111111 is not on/u), + ).toBeTruthy(); + expect(panel.querySelectorAll('button, a')).toHaveLength(0); + }); + + it('separates an unavailable authorization from a denial', () => { + renderScenario('auth-unavailable'); + const panel = screen.getByRole('region', { name: 'Authorization' }); + expect(within(panel).getByText(/neither an approval nor a denial/u)).toBeTruthy(); + expect(within(panel).queryByText('Denied')).toBeNull(); + }); +}); + +describe('settlement states', () => { + it.each([ + ['auth-checking', 'Awaiting authorization'], + ['ready-authorized', 'Ready'], + ['submitting-in-flight', 'Submitting'], + ['unknown-reconcile-only', 'Unknown'], + ['authorized-committed', 'Committed'], + ['final-revert', 'Failed safe'], + ['auth-denied-recipient', 'Rejected'], + ])('labels %s as %s', (name, label) => { + renderScenario(name); + const panel = screen.getByRole('region', { name: 'Settlement' }); + expect(within(panel).getByText(label)).toBeTruthy(); + }); + + it('keeps UNKNOWN visibly non-terminal and free of actions', () => { + const { container } = renderScenario('unknown-reconcile-only'); + const panel = screen.getByRole('region', { name: 'Settlement' }); + expect(within(panel).getByText('Not final')).toBeTruthy(); + expect( + within(panel).getByText(/No new settlement action is available for an unknown outcome/u), + ).toBeTruthy(); + expect(within(panel).getByText('Pending')).toBeTruthy(); + expect(container.querySelectorAll('button, a')).toHaveLength(0); + }); + + it('renders a lagging index observation without treating it as proof', () => { + renderScenario('unknown-reconcile-only'); + const panel = screen.getByRole('region', { name: 'Settlement' }); + expect(within(panel).getByText('THE_GRAPH')).toBeTruthy(); + expect(within(panel).getByText('OBSERVATION')).toBeTruthy(); + expect(within(panel).getByText('LAGGING')).toBeTruthy(); + expect( + within(panel).getByText(/absence of an index result is not proof that no payment happened/u), + ).toBeTruthy(); + }); + + it('states that unavailable evidence does not change authoritative state', () => { + renderScenario('auth-checking'); + const panel = screen.getByRole('region', { name: 'Settlement' }); + expect( + within(panel).getByText(/Missing evidence does not change the authoritative state/u), + ).toBeTruthy(); + }); +}); + +describe('verified transaction details', () => { + it('renders the full transfer identity for a verified settlement', () => { + renderScenario('authorized-committed'); + const panel = screen.getByRole('region', { name: 'Transaction' }); + expect(within(panel).getByText('Verified')).toBeTruthy(); + expect( + within(panel).getByText('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + ).toBeTruthy(); + expect(within(panel).getByText('100')).toBeTruthy(); + expect(within(panel).getByText('0x3600000000000000000000000000000000000000')).toBeTruthy(); + expect(within(panel).getByText('0x1111111111111111111111111111111111111111')).toBeTruthy(); + expect(within(panel).getByText('1.250000')).toBeTruthy(); + expect(within(panel).getByText('log index 0')).toBeTruthy(); + }); + + it('links to the Arc explorer through a validated href', () => { + renderScenario('authorized-committed'); + const link = screen.getByRole('link', { name: 'View on the Arc explorer' }); + expect(link.getAttribute('href')).toBe( + 'https://testnet.arcscan.io/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + expect(link.getAttribute('rel')).toContain('noopener'); + }); + + it('reaches the explorer link by keyboard', async () => { + const user = userEvent.setup(); + renderScenario('authorized-committed'); + const link = screen.getByRole('link', { name: 'View on the Arc explorer' }); + await user.tab(); + expect(document.activeElement).toBe(link); + }); + + it('drops an unsafe explorer link and explains why', () => { + renderScenario('hostile-explorer-link'); + expect(screen.queryByRole('link')).toBeNull(); + const panel = screen.getByRole('region', { name: 'Transaction' }); + expect(within(panel).getByText('Explorer link must use https.')).toBeTruthy(); + expect(panel.innerHTML).not.toContain('javascript:'); + }); + + it('drops a link whose host is off the deployment allowlist', () => { + const scenario = SETTLEMENT_SCENARIOS['authorized-committed']; + if (scenario === undefined) throw new Error('Missing fixture: authorized-committed'); + render( + createElement(SettlementDetailsPanel, { + view: toSettlementDetailsView(scenario.intent), + }), + ); + expect(screen.queryByRole('link')).toBeNull(); + const panel = screen.getByRole('region', { name: 'Transaction' }); + expect(within(panel).getByText('Explorer host is not on the allowlist.')).toBeTruthy(); + }); + + it('escapes a hostile string rather than injecting markup', () => { + const { container } = renderScenario('hostile-explorer-link'); + expect(container.querySelector('script')).toBeNull(); + expect(container.textContent ?? '').toContain(''); + }); + + it('withholds details when the settlement is not verified', () => { + renderScenario('committed-without-arc-evidence'); + const panel = screen.getByRole('region', { name: 'Transaction' }); + expect(within(panel).getByText('Unverified')).toBeTruthy(); + expect(panel.textContent ?? '').not.toContain( + '0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + ); + }); + + it('reports no transaction when none is recorded', () => { + renderScenario('auth-checking'); + const panel = screen.getByRole('region', { name: 'Transaction' }); + expect(within(panel).getByText('None recorded')).toBeTruthy(); + }); +}); + +describe('redaction', () => { + it.each(SCENARIOS.map((scenario) => scenario.scenario))( + 'renders no secret-shaped markup for %s', + (name) => { + const { container } = renderScenario(name); + const markup = container.innerHTML.toLowerCase(); + for (const forbidden of [ + 'secret', + 'private key', + 'seed phrase', + 'mnemonic', + 'bearer ', + 'signature', + ]) { + expect(markup).not.toContain(forbidden); + } + }, + ); +}); + +describe('responsive layout', () => { + it('ships narrow and medium breakpoints', async () => { + const css = await readFile(join(packageRoot(), 'src', 'styles.css'), 'utf8'); + expect(css).toContain('@media (max-width: 860px)'); + expect(css).toContain('@media (max-width: 620px)'); + expect(css).toContain('grid-template-columns: 1fr'); + }); +}); diff --git a/packages/settlement-ui/test/contract.test.ts b/packages/settlement-ui/test/contract.test.ts new file mode 100644 index 0000000..a0bb4dd --- /dev/null +++ b/packages/settlement-ui/test/contract.test.ts @@ -0,0 +1,352 @@ +import type { IntentResponse } from '@oneshot/contracts'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_EXPLORER_HOSTS, + SanitizationError, + assertNoSensitiveFields, + sanitizeText, + toSettlementDetailsView, + validateExplorerUrl, +} from '../src/contract.js'; +import { FIXTURE_EXPLORER_HOSTS, SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; + +const COMMITTED = SETTLEMENT_SCENARIOS['authorized-committed']?.intent as IntentResponse; +const HASH = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +function scenarioIntent(name: string): IntentResponse { + const scenario = SETTLEMENT_SCENARIOS[name]; + if (scenario === undefined) throw new Error(`Missing fixture: ${name}`); + return scenario.intent; +} + +describe('outbound explorer URL validation', () => { + it('accepts an https link that references the displayed transaction', () => { + const result = validateExplorerUrl(`https://testnet.arcscan.app/tx/${HASH}`, HASH); + expect(result.href).toBe(`https://testnet.arcscan.app/tx/${HASH}`); + expect(result.rejectedReason).toBeNull(); + }); + + it('accepts the hash in a query parameter', () => { + const result = validateExplorerUrl(`https://testnet.arcscan.app/search?tx=${HASH}`, HASH); + expect(result.href).toBe(`https://testnet.arcscan.app/search?tx=${HASH}`); + }); + + it.each([ + ['javascript:alert(1)', 'scheme'], + ['JavaScript:alert(1)', 'uppercase scheme'], + [`data:text/html,${HASH}`, 'data URL'], + [`http://testnet.arcscan.app/tx/${HASH}`, 'plaintext http'], + [`vbscript:msgbox(${HASH})`, 'vbscript'], + [`https://user:pass@evil.example/tx/${HASH}`, 'embedded credentials'], + ['https://testnet.arcscan.app/tx/0xdeadbeef', 'different transaction'], + ['https://testnet.arcscan.app/tx/', 'no transaction reference'], + ['/tx/relative', 'relative URL'], + ['not a url', 'unparsable'], + [`https://testnet.arcscan.app/tx/${HASH}\nlocation=1`, 'embedded newline'], + ])('rejects %j (%s)', (candidate) => { + const result = validateExplorerUrl(candidate, HASH); + expect(result.href).toBeNull(); + expect(result.rejectedReason).not.toBeNull(); + }); + + it('rejects a link longer than the contract bound', () => { + const long = `https://testnet.arcscan.app/tx/${HASH}?padding=${'a'.repeat(300)}`; + expect(validateExplorerUrl(long, HASH).href).toBeNull(); + }); + + it('reports no link and no rejection when the field is absent', () => { + expect(validateExplorerUrl(undefined, HASH)).toEqual({ href: null, rejectedReason: null }); + expect(validateExplorerUrl(' ', HASH)).toEqual({ href: null, rejectedReason: null }); + }); + + it('refuses to bind a link to a malformed transaction hash', () => { + expect(validateExplorerUrl('https://testnet.arcscan.app/tx/0xabc', '0xabc').href).toBeNull(); + }); + + it('rejects a hostile host that quotes the real transaction hash back', () => { + const result = validateExplorerUrl(`https://arcscan-app.example/tx/${HASH}`, HASH); + expect(result.href).toBeNull(); + expect(result.rejectedReason).toBe('Explorer host is not on the allowlist.'); + }); + + it.each([ + `https://evil.example/tx/${HASH}`, + `https://testnet.arcscan.app.evil.example/tx/${HASH}`, + `https://sub.testnet.arcscan.app/tx/${HASH}`, + ])('rejects the off-allowlist host %j', (candidate) => { + expect(validateExplorerUrl(candidate, HASH).rejectedReason).toBe( + 'Explorer host is not on the allowlist.', + ); + }); + + it('accepts a host on a caller-supplied allowlist, case-insensitively', () => { + const result = validateExplorerUrl(`https://Explorer.Example/tx/${HASH}`, HASH, [ + 'explorer.example', + ]); + expect(result.href).toBe(`https://explorer.example/tx/${HASH}`); + }); + + it('rejects every host when the allowlist is empty', () => { + expect(validateExplorerUrl(`https://testnet.arcscan.app/tx/${HASH}`, HASH, []).href).toBeNull(); + }); + + it('defaults to the documented Arc testnet explorer host', () => { + expect(DEFAULT_EXPLORER_HOSTS).toEqual(['testnet.arcscan.app']); + }); +}); + +describe('sensitive field rejection', () => { + it.each([ + ['app_secret', { app_secret: 'x' }], + ['private_key', { private_key: 'x' }], + ['seed_phrase', { seed_phrase: 'x' }], + ['signature', { signature: '0xsig' }], + ['access_token', { access_token: 'x' }], + ['raw_policy_response', { raw_policy_response: {} }], + ])('throws when the payload carries %s', (_label, extra) => { + expect(() => assertNoSensitiveFields({ ...COMMITTED, ...extra })).toThrow(SanitizationError); + }); + + it('rejects a sensitive field nested inside an array element', () => { + const payload = { attempts: [{ attempt_id: 'a', wallet_credential: 'x' }] }; + expect(() => assertNoSensitiveFields(payload)).toThrow(SanitizationError); + }); + + it('accepts every published fixture', () => { + for (const scenario of Object.values(SETTLEMENT_SCENARIOS)) { + expect(() => assertNoSensitiveFields(scenario.intent)).not.toThrow(); + } + }); +}); + +describe('text sanitization', () => { + it('strips control characters', () => { + expect(sanitizeText('Invoice\u0000 INV-1001\u001b')).toBe('Invoice INV-1001'); + }); + + it('bounds length', () => { + const sanitized = sanitizeText('x'.repeat(400)); + expect(sanitized).not.toBeNull(); + expect(sanitized?.length).toBe(256); + }); + + it('returns null for empty or non-string input', () => { + expect(sanitizeText(' ')).toBeNull(); + expect(sanitizeText(undefined)).toBeNull(); + expect(sanitizeText(null)).toBeNull(); + }); +}); + +describe('settlement details projection', () => { + it('projects every published scenario without throwing', () => { + for (const scenario of Object.values(SETTLEMENT_SCENARIOS)) { + expect(() => toSettlementDetailsView(scenario.intent)).not.toThrow(); + } + }); + + it('marks UNKNOWN as non-terminal', () => { + const view = toSettlementDetailsView(scenarioIntent('unknown-reconcile-only')); + expect(view.phase).toBe('PENDING_UNKNOWN'); + expect(view.terminal).toBe(false); + expect(view.transaction).toBeNull(); + }); + + it.each([ + ['authorized-committed', true], + ['auth-denied-recipient', true], + ['auth-cap-exceeded', true], + ['auth-config-mismatch', true], + ['auth-unavailable', true], + ['auth-checking', false], + ['ready-authorized', false], + ['submitting-in-flight', false], + ['unknown-reconcile-only', false], + ])('derives terminality for %s as %s', (name, terminal) => { + expect(toSettlementDetailsView(scenarioIntent(name)).terminal).toBe(terminal); + }); + + it('verifies a committed settlement backed by authoritative Arc evidence', () => { + const view = toSettlementDetailsView(scenarioIntent('authorized-committed'), { + allowedExplorerHosts: FIXTURE_EXPLORER_HOSTS, + }); + expect(view.verification).toBe('VERIFIED'); + expect(view.transaction?.transactionHash).toBe(HASH); + expect(view.transaction?.amountDisplay).toBe('1.250000'); + expect(view.transaction?.tokenContract).toBe('0x3600000000000000000000000000000000000000'); + expect(view.transaction?.transferLogIndex).toBe(0); + expect(view.transaction?.explorer.href).toBe(`https://testnet.arcscan.io/tx/${HASH}`); + }); + + it('withholds transaction details when Arc evidence is not authoritative', () => { + const view = toSettlementDetailsView(scenarioIntent('committed-without-arc-evidence')); + expect(view.verification).toBe('UNVERIFIED'); + expect(view.transaction).toBeNull(); + }); + + it('keeps a reverted settlement out of the verified path', () => { + const view = toSettlementDetailsView(scenarioIntent('final-revert')); + expect(view.phase).toBe('FINAL_FAILED_SAFE'); + expect(view.verification).toBe('UNVERIFIED'); + expect(view.transaction).toBeNull(); + }); + + it('drops an unsafe explorer link on an otherwise verified settlement', () => { + const view = toSettlementDetailsView(scenarioIntent('hostile-explorer-link'), { + allowedExplorerHosts: FIXTURE_EXPLORER_HOSTS, + }); + expect(view.verification).toBe('VERIFIED'); + expect(view.transaction?.explorer.href).toBeNull(); + expect(view.transaction?.explorer.rejectedReason).toBe('Explorer link must use https.'); + }); + + it('reports cap comparison from integer atomic units', () => { + const withinCap = toSettlementDetailsView(scenarioIntent('authorized-committed')).policy; + expect(withinCap.amountWithinCap).toBe(true); + expect(withinCap.settlementCapDisplay).toBe('10.000000'); + + const exceeded = toSettlementDetailsView(scenarioIntent('auth-cap-exceeded')).policy; + expect(exceeded.amountWithinCap).toBe(false); + expect(exceeded.status).toBe('EXCEEDED'); + }); + + it('treats a missing allowlist as unknown rather than allowed', () => { + const view = toSettlementDetailsView(scenarioIntent('auth-config-mismatch')); + expect(view.policy.recipientAllowlisted).toBeNull(); + expect(view.policy.status).toBe('NOT_CONFIGURED'); + }); + + it('flags a recipient that is absent from a reported allowlist', () => { + const view = toSettlementDetailsView(scenarioIntent('auth-denied-recipient')); + expect(view.policy.recipientAllowlisted).toBe(false); + expect(view.authorization.status).toBe('DENIED'); + expect(view.authorization.terminal).toBe(true); + }); + + it('drops a malformed cap instead of comparing against it', () => { + const intent: IntentResponse = { + ...COMMITTED, + policy: { status: 'CONFIGURED', settlement_cap_atomic: '10.00' }, + }; + const view = toSettlementDetailsView(intent); + expect(view.policy.settlementCapAtomic).toBeNull(); + expect(view.policy.amountWithinCap).toBeNull(); + }); + + it('drops a malformed allowlist entry', () => { + const intent: IntentResponse = { + ...COMMITTED, + policy: { status: 'CONFIGURED', allowed_recipients: ['not-an-address'] }, + }; + expect(toSettlementDetailsView(intent).policy.allowedRecipients).toEqual([]); + }); + + it('refuses to verify a settlement with a malformed transaction hash', () => { + const settlement = COMMITTED.settlement; + if (settlement === undefined) throw new Error('fixture is missing its settlement'); + const intent: IntentResponse = { + ...COMMITTED, + settlement: { ...settlement, transaction_hash: '0xshort' }, + }; + const view = toSettlementDetailsView(intent); + expect(view.verification).toBe('UNVERIFIED'); + expect(view.transaction).toBeNull(); + }); + + it('reports evidence availability without inferring non-payment from absence', () => { + const view = toSettlementDetailsView(scenarioIntent('auth-checking')); + expect(view.evidenceAvailable).toBe(false); + expect(view.evidence).toEqual([]); + expect(view.state).toBe('AUTHORIZING'); + }); + + it('selects the attempt with the latest timestamp, not the last array element', () => { + const intent: IntentResponse = { + ...COMMITTED, + attempts: [ + { + attempt_id: 'attempt-newest', + stage: 'REJECTED', + created_at: '2026-09-08T12:30:00.000Z', + authorization_status: 'DENIED', + }, + { + attempt_id: 'attempt-oldest', + stage: 'AUTHORIZING', + created_at: '2026-09-08T12:00:00.000Z', + authorization_status: 'CHECKING', + }, + ], + }; + const view = toSettlementDetailsView(intent); + expect(view.authorization.attemptId).toBe('attempt-newest'); + expect(view.authorization.status).toBe('DENIED'); + }); + + it('keeps the later element when two attempts share a timestamp', () => { + const intent: IntentResponse = { + ...COMMITTED, + attempts: [ + { + attempt_id: 'attempt-first', + stage: 'AUTHORIZING', + created_at: '2026-09-08T12:00:00.000Z', + authorization_status: 'CHECKING', + }, + { + attempt_id: 'attempt-second', + stage: 'READY', + created_at: '2026-09-08T12:00:00.000Z', + authorization_status: 'AUTHORIZED', + }, + ], + }; + expect(toSettlementDetailsView(intent).authorization.attemptId).toBe('attempt-second'); + }); + + it('never lets an undated attempt displace a dated one', () => { + const intent: IntentResponse = { + ...COMMITTED, + attempts: [ + { + attempt_id: 'attempt-dated', + stage: 'READY', + created_at: '2026-09-08T12:00:00.000Z', + authorization_status: 'AUTHORIZED', + }, + { + attempt_id: 'attempt-undated', + stage: 'AUTHORIZING', + created_at: 'not-a-date', + authorization_status: 'CHECKING', + }, + ], + }; + expect(toSettlementDetailsView(intent).authorization.attemptId).toBe('attempt-dated'); + }); + + it('falls back to the first attempt when no timestamp parses', () => { + const intent: IntentResponse = { + ...COMMITTED, + attempts: [ + { attempt_id: 'attempt-a', stage: 'AUTHORIZING', created_at: 'nope' }, + { attempt_id: 'attempt-b', stage: 'AUTHORIZING', created_at: 'also-nope' }, + ], + }; + expect(toSettlementDetailsView(intent).authorization.attemptId).toBe('attempt-a'); + }); + + it('reports no attempt when the list is empty', () => { + const intent: IntentResponse = { ...COMMITTED, attempts: [] }; + const view = toSettlementDetailsView(intent); + expect(view.authorization.attemptId).toBeNull(); + expect(view.authorization.status).toBe('NOT_REPORTED'); + }); + + it('preserves lagging index freshness as an observation', () => { + const view = toSettlementDetailsView(scenarioIntent('unknown-reconcile-only')); + const graph = view.evidence.find((entry) => entry.source === 'THE_GRAPH'); + expect(graph?.authorityClass).toBe('OBSERVATION'); + expect(graph?.freshness).toBe('LAGGING'); + }); +}); diff --git a/packages/settlement-ui/test/money.test.ts b/packages/settlement-ui/test/money.test.ts new file mode 100644 index 0000000..97b9d4b --- /dev/null +++ b/packages/settlement-ui/test/money.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + compareAtomic, + formatAtomicUsdc, + formatAtomicUsdcWithAsset, + isAtomicAmount, +} from '../src/money.js'; + +describe('exact USDC formatting', () => { + it.each([ + ['0', '0.000000'], + ['1', '0.000001'], + ['999999', '0.999999'], + ['1000000', '1.000000'], + ['1250000', '1.250000'], + ['50000000', '50.000000'], + ['123456789012345678901234567890', '123456789012345678901234.567890'], + ])('formats %s atomic units as %s', (atomic, expected) => { + expect(formatAtomicUsdc(atomic)).toBe(expected); + }); + + it('keeps precision that a double would lose', () => { + const atomic = '9007199254740993'; + expect(formatAtomicUsdc(atomic)).toBe('9007199254.740993'); + expect(formatAtomicUsdc(atomic)).not.toBe(`${Number(atomic) / 1_000_000}`); + }); + + it.each(['', ' ', '-1', '01', '1.5', '1e6', '1_000', 'NaN', '0x10', '1250000 '])( + 'refuses the malformed amount %j', + (value) => { + expect(isAtomicAmount(value)).toBe(false); + expect(formatAtomicUsdc(value)).toBeNull(); + }, + ); + + it('appends the asset symbol only for a well-formed amount', () => { + expect(formatAtomicUsdcWithAsset('1250000', 'USDC')).toBe('1.250000 USDC'); + expect(formatAtomicUsdcWithAsset('1.25', 'USDC')).toBeNull(); + }); +}); + +describe('atomic comparison', () => { + it('compares beyond the safe-integer range', () => { + expect(compareAtomic('9007199254740993', '9007199254740992')).toBe(1); + expect(compareAtomic('9007199254740992', '9007199254740993')).toBe(-1); + expect(compareAtomic('1250000', '1250000')).toBe(0); + }); + + it('fails closed on a malformed operand', () => { + expect(compareAtomic('1.25', '1250000')).toBeNull(); + expect(compareAtomic('1250000', 'unbounded')).toBeNull(); + }); +}); diff --git a/packages/settlement-ui/test/route.test.ts b/packages/settlement-ui/test/route.test.ts new file mode 100644 index 0000000..93336cb --- /dev/null +++ b/packages/settlement-ui/test/route.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment jsdom + +import type { IntentResponse } from '@oneshot/contracts'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + SettlementClientError, + createInMemorySettlementClient, + createMockSettlementClient, + createSettlementClient, + type SettlementClient, +} from '../src/client.js'; +import { SETTLEMENT_SCENARIOS, SETTLEMENT_SCENARIO_INTENTS } from '../src/fixtures.js'; +import { SettlementDetailsRoute } from '../src/SettlementDetailsRoute.js'; + +afterEach(cleanup); + +const COMMITTED_ID = '018f-ui-committed-001'; + +function renderRoute(businessIntentId: string, client: SettlementClient) { + return render(createElement(SettlementDetailsRoute, { businessIntentId, client })); +} + +describe('settlement details route', () => { + it('renders the loading state before the read resolves', () => { + const client: SettlementClient = { readIntent: () => new Promise(() => {}) }; + const { container } = renderRoute(COMMITTED_ID, client); + expect(screen.getByText('Loading settlement details…')).toBeTruthy(); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + }); + + it('renders the composed slice once the intent resolves', async () => { + const client = createInMemorySettlementClient(SETTLEMENT_SCENARIO_INTENTS); + renderRoute(COMMITTED_ID, client); + await waitFor(() => { + expect(screen.getByRole('heading', { level: 2, name: 'Transaction' })).toBeTruthy(); + }); + expect(screen.getByRole('heading', { level: 1, name: COMMITTED_ID })).toBeTruthy(); + }); + + it.each([ + ['INTENT_NOT_FOUND', 'Business Intent not found'], + ['EVIDENCE_UNAVAILABLE', 'Evidence unavailable'], + ['UNAUTHORIZED', 'Not authorized'], + ['TRANSPORT_UNAVAILABLE', 'Service unavailable'], + ] as const)('renders the %s failure as %s', async (failure, heading) => { + const client = createInMemorySettlementClient({}, { [COMMITTED_ID]: failure }); + renderRoute(COMMITTED_ID, client); + await waitFor(() => { + expect(screen.getByRole('alert')).toBeTruthy(); + }); + expect(screen.getByRole('heading', { level: 1, name: heading })).toBeTruthy(); + }); + + it('says unavailable evidence is not proof that no payment happened', async () => { + const client = createInMemorySettlementClient({}, { [COMMITTED_ID]: 'EVIDENCE_UNAVAILABLE' }); + renderRoute(COMMITTED_ID, client); + await waitFor(() => { + expect( + screen.getByText(/unavailable evidence is not proof that no payment happened/u), + ).toBeTruthy(); + }); + }); + + it('withholds a response that carries a sensitive field', async () => { + const client: SettlementClient = { + readIntent: () => + Promise.resolve({ + ...(SETTLEMENT_SCENARIOS['authorized-committed']?.intent as IntentResponse), + wallet_private_key: '0xdeadbeef', + } as unknown as IntentResponse), + }; + const { container } = renderRoute(COMMITTED_ID, client); + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1, name: 'Response withheld' })).toBeTruthy(); + }); + expect(container.innerHTML).not.toContain('0xdeadbeef'); + }); + + it('exposes no interactive control in any failure state', async () => { + const client = createInMemorySettlementClient({}, { [COMMITTED_ID]: 'EVIDENCE_UNAVAILABLE' }); + const { container } = renderRoute(COMMITTED_ID, client); + await waitFor(() => { + expect(screen.getByRole('alert')).toBeTruthy(); + }); + expect(container.querySelectorAll('button, a, input')).toHaveLength(0); + }); +}); + +describe('settlement client', () => { + it('reads an intent through the frozen mock server', async () => { + const client = createMockSettlementClient(); + const intent = await client.readIntent(COMMITTED_ID); + expect(intent.business_intent_id).toBe(COMMITTED_ID); + expect(intent.state).toBe('COMMITTED'); + }); + + it('maps HTTP status codes to failure kinds', async () => { + const statuses = [ + [404, 'INTENT_NOT_FOUND'], + [401, 'UNAUTHORIZED'], + [403, 'UNAUTHORIZED'], + [503, 'EVIDENCE_UNAVAILABLE'], + [500, 'TRANSPORT_UNAVAILABLE'], + ] as const; + + for (const [status, failure] of statuses) { + const client = createSettlementClient({ + baseUrl: 'https://api.test', + fetcher: () => Promise.resolve(new Response('{}', { status })), + }); + await expect(client.readIntent(COMMITTED_ID)).rejects.toMatchObject({ failure }); + } + }); + + it('reports a transport failure when the request throws', async () => { + const client = createSettlementClient({ + baseUrl: 'https://api.test', + fetcher: () => Promise.reject(new Error('offline')), + }); + await expect(client.readIntent(COMMITTED_ID)).rejects.toBeInstanceOf(SettlementClientError); + }); + + it('sends the bearer token only when one is supplied', async () => { + const seen: Array> = []; + const fetcher = (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push((init?.headers ?? {}) as Record); + return Promise.resolve( + new Response(JSON.stringify(SETTLEMENT_SCENARIO_INTENTS[COMMITTED_ID]), { status: 200 }), + ); + }; + + await createSettlementClient({ baseUrl: 'https://api.test', fetcher }).readIntent(COMMITTED_ID); + await createSettlementClient({ + baseUrl: 'https://api.test', + fetcher, + getAuthToken: () => 'demo-token', + }).readIntent(COMMITTED_ID); + + expect(seen[0]?.authorization).toBeUndefined(); + expect(seen[1]?.authorization).toBe('Bearer demo-token'); + }); + + it('escapes the identifier in the request path', async () => { + const urls: string[] = []; + const client = createSettlementClient({ + baseUrl: 'https://api.test', + fetcher: (input) => { + urls.push(String(input)); + return Promise.resolve(new Response('{}', { status: 404 })); + }, + }); + await expect(client.readIntent('../../admin?x=1')).rejects.toBeInstanceOf( + SettlementClientError, + ); + expect(urls[0]).toBe('https://api.test/v1/intents/..%2F..%2Fadmin%3Fx%3D1'); + }); +}); diff --git a/packages/settlement-ui/tsconfig.json b/packages/settlement-ui/tsconfig.json new file mode 100644 index 0000000..9a4514f --- /dev/null +++ b/packages/settlement-ui/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "dist/.tsbuildinfo", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "test/**/*.ts", "vite.config.ts"], + "references": [{ "path": "../contracts" }] +} diff --git a/packages/settlement-ui/vite.config.ts b/packages/settlement-ui/vite.config.ts new file mode 100644 index 0000000..1b33876 --- /dev/null +++ b/packages/settlement-ui/vite.config.ts @@ -0,0 +1,17 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + build: { + emptyOutDir: false, + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: 'settlement-ui', + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +}); diff --git a/packages/settlement-ui/vitest.config.ts b/packages/settlement-ui/vitest.config.ts new file mode 100644 index 0000000..a44e73c --- /dev/null +++ b/packages/settlement-ui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ce6453..aea0324 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,55 @@ importers: specifier: 5.0.0 version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.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/settlement-ui: + dependencies: + '@oneshot/contracts': + specifier: workspace:* + version: link:../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/dom': + specifier: 10.4.1 + version: 10.4.1 + '@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@2.4.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)(jsdom@30.0.1(@noble/hashes@2.4.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: '@oneshot/contracts': diff --git a/tsconfig.json b/tsconfig.json index 9f9125a..36fa74f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,9 @@ { "path": "./packages/recovery-ui" }, + { + "path": "./packages/settlement-ui" + }, { "path": "./apps/web" }