From 2fbc8bd9380eb18f1aeff192b84867fbfbc906c5 Mon Sep 17 00:00:00 2001 From: selezenart Date: Tue, 8 Sep 2026 20:38:46 +0200 Subject: [PATCH 1/2] feat(settlement-ui): add B05 authorization and settlement details slice Adds @oneshot/settlement-ui, the Lane B frontend slice for Privy policy, authorization state, settlement state, and verified Arc transaction evidence, built against the Gate P4 frozen OpenAPI seam and mock server 1.0.0. Rendering rules the slice enforces: - Transaction details require a COMMITTED durable state, a well-formed Arc identity, and an authoritative Arc observation. Anything less renders as unverified with the hash withheld. - An explorer URL becomes an href only when it is https, carries no embedded credentials, and references the exact transaction hash on screen. The OpenAPI field is an unconstrained bounded string, so this validation is the consumer's responsibility. - A response carrying a secret-shaped field name is refused before projection, so no part of it reaches a component prop. - Amounts format from integer atomic units through bigint string arithmetic. A malformed amount renders as malformed, never rounded. - UNKNOWN renders as non-terminal with no settlement action. The slice exposes no submit, resend, force-pay, or policy-override control, and the client interface is read-only by construction. Five Lane B local fixtures cover states the frozen pack does not carry (READY, SUBMITTING, on-chain revert, hostile explorer link, committed without Arc evidence); published fixture digests stay immutable. --- ...180839Z-b05-frontend-settlement-details.md | 155 +++++++ packages/settlement-ui/README.md | 114 +++++ packages/settlement-ui/index.html | 13 + packages/settlement-ui/package.json | 51 +++ .../src/AuthorizationStatePanel.tsx | 93 +++++ packages/settlement-ui/src/DemoShell.tsx | 56 +++ .../settlement-ui/src/PolicySummaryPanel.tsx | 121 ++++++ .../settlement-ui/src/SettlementDetails.tsx | 33 ++ .../src/SettlementDetailsRoute.tsx | 114 +++++ .../src/SettlementStatePanel.tsx | 113 +++++ .../settlement-ui/src/TransactionDetails.tsx | 89 ++++ packages/settlement-ui/src/client.ts | 126 ++++++ packages/settlement-ui/src/contract.ts | 395 ++++++++++++++++++ packages/settlement-ui/src/fixtures.ts | 237 +++++++++++ packages/settlement-ui/src/index.ts | 13 + packages/settlement-ui/src/main.tsx | 22 + packages/settlement-ui/src/money.ts | 54 +++ packages/settlement-ui/src/styles.css | 303 ++++++++++++++ packages/settlement-ui/src/vite-env.d.ts | 1 + packages/settlement-ui/test/component.test.ts | 273 ++++++++++++ packages/settlement-ui/test/contract.test.ts | 233 +++++++++++ packages/settlement-ui/test/money.test.ts | 54 +++ packages/settlement-ui/test/route.test.ts | 160 +++++++ packages/settlement-ui/tsconfig.json | 13 + packages/settlement-ui/vite.config.ts | 17 + packages/settlement-ui/vitest.config.ts | 8 + pnpm-lock.yaml | 49 +++ tsconfig.json | 3 + 28 files changed, 2913 insertions(+) create mode 100644 .agent/context/20260908T180839Z-b05-frontend-settlement-details.md create mode 100644 packages/settlement-ui/README.md create mode 100644 packages/settlement-ui/index.html create mode 100644 packages/settlement-ui/package.json create mode 100644 packages/settlement-ui/src/AuthorizationStatePanel.tsx create mode 100644 packages/settlement-ui/src/DemoShell.tsx create mode 100644 packages/settlement-ui/src/PolicySummaryPanel.tsx create mode 100644 packages/settlement-ui/src/SettlementDetails.tsx create mode 100644 packages/settlement-ui/src/SettlementDetailsRoute.tsx create mode 100644 packages/settlement-ui/src/SettlementStatePanel.tsx create mode 100644 packages/settlement-ui/src/TransactionDetails.tsx create mode 100644 packages/settlement-ui/src/client.ts create mode 100644 packages/settlement-ui/src/contract.ts create mode 100644 packages/settlement-ui/src/fixtures.ts create mode 100644 packages/settlement-ui/src/index.ts create mode 100644 packages/settlement-ui/src/main.tsx create mode 100644 packages/settlement-ui/src/money.ts create mode 100644 packages/settlement-ui/src/styles.css create mode 100644 packages/settlement-ui/src/vite-env.d.ts create mode 100644 packages/settlement-ui/test/component.test.ts create mode 100644 packages/settlement-ui/test/contract.test.ts create mode 100644 packages/settlement-ui/test/money.test.ts create mode 100644 packages/settlement-ui/test/route.test.ts create mode 100644 packages/settlement-ui/tsconfig.json create mode 100644 packages/settlement-ui/vite.config.ts create mode 100644 packages/settlement-ui/vitest.config.ts 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..3201eb4 --- /dev/null +++ b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md @@ -0,0 +1,155 @@ +# 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 against an https-only scheme check with the + transaction hash bound to the rendered settlement before the anchor renders. + A link that fails validation is dropped, not rendered inert. +- 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, 173 tests, build) +- `pnpm lint` - PASS +- `pnpm typecheck` - PASS +- `pnpm test` - PASS (53 files, 778 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: uncommitted; the candidate tree is the staged index, captured with + `git write-tree` immediately before Gate A +- Diff: 28 files, +2893 lines, all additive except the `tsconfig.json` project + reference slot and the `pnpm-lock.yaml` workspace entry +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: NOT RUN. `free-pi-cli@0.2.19` is an interactive terminal agent with no + non-interactive prompt mode, and this session cannot drive a TTY. The gate + fails closed: nothing is committed or pushed until a human runs it. +- 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. +> 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..a9549a8 --- /dev/null +++ b/packages/settlement-ui/README.md @@ -0,0 +1,114 @@ +# 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. + +## 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, +}); + +; +``` + +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, and references the exact transaction + hash being displayed. 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 | + +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..2dd4bca --- /dev/null +++ b/packages/settlement-ui/src/DemoShell.tsx @@ -0,0 +1,56 @@ +import { useMemo, useState } from 'react'; + +import { createInMemorySettlementClient } from './client.js'; +import { 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..339ad00 --- /dev/null +++ b/packages/settlement-ui/src/SettlementDetailsRoute.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react'; + +import { + SettlementClientError, + type SettlementClient, + type SettlementClientFailure, +} from './client.js'; +import { + SanitizationError, + toSettlementDetailsView, + type SettlementDetailsView, +} from './contract.js'; +import { SettlementDetailsPanel } from './SettlementDetails.js'; + +export interface SettlementDetailsRouteProps { + readonly businessIntentId: string; + readonly client: SettlementClient; +} + +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 }: SettlementDetailsRouteProps) { + const [state, setState] = useState({ kind: 'LOADING' }); + + useEffect(() => { + let active = true; + setState({ kind: 'LOADING' }); + + void client + .readIntent(businessIntentId) + .then((intent) => { + if (!active) return; + setState({ kind: 'LOADED', view: toSettlementDetailsView(intent) }); + }) + .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]); + + 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..9c4e057 --- /dev/null +++ b/packages/settlement-ui/src/contract.ts @@ -0,0 +1,395 @@ +import { + OPENAPI_MOCK_SERVER_VERSION, + 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; +} + +/** + * 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, and the link must reference + * the exact transaction hash being displayed. A link that cannot be proven to + * point at this transaction is dropped rather than rendered. + */ +export function validateExplorerUrl( + rawUrl: string | undefined | null, + transactionHash: string, +): 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.' }; + } + 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', + ); +} + +/** + * 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): 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 latestAttempt = intent.attempts.at(-1) ?? null; + const authorizationStatus: AuthorizationDisplayStatus = + latestAttempt?.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), + } + : 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: latestAttempt?.attempt_id ?? null, + occurredAt: latestAttempt?.created_at ?? null, + sanitizedReason: sanitizeText(latestAttempt?.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..27ebc76 --- /dev/null +++ b/packages/settlement-ui/src/fixtures.ts @@ -0,0 +1,237 @@ +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'; + +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..ceb4480 --- /dev/null +++ b/packages/settlement-ui/test/component.test.ts @@ -0,0 +1,273 @@ +// @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 { 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) }), + ); +} + +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('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..56673aa --- /dev/null +++ b/packages/settlement-ui/test/contract.test.ts @@ -0,0 +1,233 @@ +import type { IntentResponse } from '@oneshot/contracts'; +import { describe, expect, it } from 'vitest'; + +import { + SanitizationError, + assertNoSensitiveFields, + sanitizeText, + toSettlementDetailsView, + validateExplorerUrl, +} from '../src/contract.js'; +import { 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.io/tx/${HASH}`, HASH); + expect(result.href).toBe(`https://testnet.arcscan.io/tx/${HASH}`); + expect(result.rejectedReason).toBeNull(); + }); + + it('accepts the hash in a query parameter', () => { + const result = validateExplorerUrl(`https://testnet.arcscan.io/search?tx=${HASH}`, HASH); + expect(result.href).toBe(`https://testnet.arcscan.io/search?tx=${HASH}`); + }); + + it.each([ + ['javascript:alert(1)', 'scheme'], + ['JavaScript:alert(1)', 'uppercase scheme'], + [`data:text/html,${HASH}`, 'data URL'], + [`http://testnet.arcscan.io/tx/${HASH}`, 'plaintext http'], + [`vbscript:msgbox(${HASH})`, 'vbscript'], + [`https://user:pass@evil.example/tx/${HASH}`, 'embedded credentials'], + ['https://testnet.arcscan.io/tx/0xdeadbeef', 'different transaction'], + ['https://testnet.arcscan.io/tx/', 'no transaction reference'], + ['/tx/relative', 'relative URL'], + ['not a url', 'unparsable'], + [`https://testnet.arcscan.io/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.io/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.io/tx/0xabc', '0xabc').href).toBeNull(); + }); +}); + +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')); + 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')); + 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('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" } From 1b3664eef73e5e82d69a204858414b589cc0faf7 Mon Sep 17 00:00:00 2001 From: selezenart Date: Tue, 8 Sep 2026 22:01:52 +0200 Subject: [PATCH 2/2] fix(settlement-ui): pin explorer hosts and select attempts by timestamp Addresses the Gate A non-blocking findings on the B05 slice. An explorer link now has to sit on a host allowlist, defaulting to the Arc testnet explorer documented in docs/settlement/PROVIDER_SETUP.md. Hash binding alone could not stop a hostile host quoting the real transaction hash back, so the allowlist is what keeps a spoofed response from producing a clickable link. SettlementDetailsRoute accepts allowedExplorerHosts for a deployment on a different explorer. The synthetic fixtures publish links through a host that is not the documented explorer, so FIXTURE_EXPLORER_HOSTS lets the fixture viewer and component tests opt into it rather than widening the default. Under the default allowlist those fixture links are refused, and a test asserts exactly that. The displayed attempt is now selected by latest created_at instead of array position, which the contract never promised. An equal timestamp keeps the later element, and an attempt whose timestamp cannot be parsed never displaces one that carries a usable date. Also corrects the insertion count recorded for the first commit. --- ...180839Z-b05-frontend-settlement-details.md | 52 +++++-- packages/settlement-ui/README.md | 16 +- packages/settlement-ui/src/DemoShell.tsx | 7 +- .../src/SettlementDetailsRoute.tsx | 16 +- packages/settlement-ui/src/contract.ts | 74 +++++++-- packages/settlement-ui/src/fixtures.ts | 7 + packages/settlement-ui/test/component.test.ts | 21 ++- packages/settlement-ui/test/contract.test.ts | 145 ++++++++++++++++-- 8 files changed, 292 insertions(+), 46 deletions(-) diff --git a/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md index 3201eb4..13f7af5 100644 --- a/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md +++ b/.agent/context/20260908T180839Z-b05-frontend-settlement-details.md @@ -49,9 +49,13 @@ readiness audit confirmed Gate P4 froze the frontend boundary. - 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 against an https-only scheme check with the - transaction hash bound to the rendered settlement before the anchor renders. - A link that fails validation is dropped, not rendered inert. +- 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 @@ -69,10 +73,10 @@ readiness audit confirmed Gate P4 froze the frontend boundary. - `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, 173 tests, build) +- `pnpm --filter @oneshot/settlement-ui verify` - PASS (format, lint, typecheck, 186 tests, build) - `pnpm lint` - PASS - `pnpm typecheck` - PASS -- `pnpm test` - PASS (53 files, 778 tests; 49 files and 605 tests on the base) +- `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 @@ -118,18 +122,34 @@ for a presentational slice and remain proven by A03/A04 and Gate P4. - Branch: `milestone/b05-frontend-settlement-details` - Base: `develop` (`710614af76ae5c28e2c1f69b2c00480f47b623b7`) -- Commit: uncommitted; the candidate tree is the staged index, captured with - `git write-tree` immediately before Gate A -- Diff: 28 files, +2893 lines, all additive except the `tsconfig.json` project - reference slot and the `pnpm-lock.yaml` workspace entry -- PR: not created -- CI: not applicable +- 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: NOT RUN. `free-pi-cli@0.2.19` is an interactive terminal agent with no - non-interactive prompt mode, and this session cannot drive a TTY. The gate - fails closed: nothing is committed or pushed until a human runs it. +- 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 @@ -139,7 +159,9 @@ 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. +> 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`. diff --git a/packages/settlement-ui/README.md b/packages/settlement-ui/README.md index a9549a8..a5c5567 100644 --- a/packages/settlement-ui/README.md +++ b/packages/settlement-ui/README.md @@ -19,6 +19,8 @@ and exposes no settlement, retry, resend, or policy-override action. - `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 @@ -37,6 +39,9 @@ const client = createSettlementClient({ ; ``` +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. @@ -61,8 +66,10 @@ P5 composition stays a single-editor change in the shell. 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, and references the exact transaction - hash being displayed. Anything else is dropped with a stated reason. + 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. @@ -92,6 +99,11 @@ immutable: | `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 diff --git a/packages/settlement-ui/src/DemoShell.tsx b/packages/settlement-ui/src/DemoShell.tsx index 2dd4bca..f76c001 100644 --- a/packages/settlement-ui/src/DemoShell.tsx +++ b/packages/settlement-ui/src/DemoShell.tsx @@ -1,7 +1,11 @@ import { useMemo, useState } from 'react'; import { createInMemorySettlementClient } from './client.js'; -import { SETTLEMENT_SCENARIOS, SETTLEMENT_SCENARIO_INTENTS } from './fixtures.js'; +import { + FIXTURE_EXPLORER_HOSTS, + SETTLEMENT_SCENARIOS, + SETTLEMENT_SCENARIO_INTENTS, +} from './fixtures.js'; import { SettlementDetailsRoute } from './SettlementDetailsRoute.js'; export interface DemoShellProps { @@ -50,6 +54,7 @@ export function DemoShell({ initialScenario }: DemoShellProps) { ); diff --git a/packages/settlement-ui/src/SettlementDetailsRoute.tsx b/packages/settlement-ui/src/SettlementDetailsRoute.tsx index 339ad00..c362d6f 100644 --- a/packages/settlement-ui/src/SettlementDetailsRoute.tsx +++ b/packages/settlement-ui/src/SettlementDetailsRoute.tsx @@ -9,12 +9,15 @@ 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 = @@ -50,18 +53,25 @@ const FAILURE_COPY: Readonly({ 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) }); + setState({ kind: 'LOADED', view: toSettlementDetailsView(intent, options) }); }) .catch((error: unknown) => { if (!active) return; @@ -89,7 +99,7 @@ export function SettlementDetailsRoute({ businessIntentId, client }: SettlementD return () => { active = false; }; - }, [businessIntentId, client]); + }, [businessIntentId, client, allowedExplorerHosts]); if (state.kind === 'LOADING') { return ( diff --git a/packages/settlement-ui/src/contract.ts b/packages/settlement-ui/src/contract.ts index 9c4e057..50f1aed 100644 --- a/packages/settlement-ui/src/contract.ts +++ b/packages/settlement-ui/src/contract.ts @@ -1,5 +1,6 @@ import { OPENAPI_MOCK_SERVER_VERSION, + type AttemptView, type AuthorizationStatus, type EvidenceView, type IntentResponse, @@ -124,17 +125,27 @@ export interface ExplorerLink { 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, and the link must reference - * the exact transaction hash being displayed. A link that cannot be proven to - * point at this transaction is dropped rather than rendered. + * 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 }; @@ -162,6 +173,10 @@ export function validateExplorerUrl( 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.' }; } @@ -291,6 +306,38 @@ function hasAuthoritativeArcEvidence(evidence: readonly EvidenceView[]): boolean ); } +/** + * 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. * @@ -298,7 +345,10 @@ function hasAuthoritativeArcEvidence(evidence: readonly EvidenceView[]): boolean * 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): SettlementDetailsView { +export function toSettlementDetailsView( + intent: IntentResponse, + options: SettlementViewOptions = {}, +): SettlementDetailsView { assertNoSensitiveFields(intent); const state = intent.state; @@ -314,9 +364,9 @@ export function toSettlementDetailsView(intent: IntentResponse): SettlementDetai ); const capComparison = capAtomic === null ? null : compareAtomic(amountAtomic, capAtomic); - const latestAttempt = intent.attempts.at(-1) ?? null; + const displayedAttempt = latestAttempt(intent.attempts); const authorizationStatus: AuthorizationDisplayStatus = - latestAttempt?.authorization_status ?? 'NOT_REPORTED'; + displayedAttempt?.authorization_status ?? 'NOT_REPORTED'; const settlement = intent.settlement ?? null; const settlementIsWellFormed = @@ -350,7 +400,11 @@ export function toSettlementDetailsView(intent: IntentResponse): SettlementDetai recipient: intent.recipient, amountAtomic, amountDisplay: formatAtomicUsdc(amountAtomic), - explorer: validateExplorerUrl(settlement.explorer_url, settlement.transaction_hash), + explorer: validateExplorerUrl( + settlement.explorer_url, + settlement.transaction_hash, + options.allowedExplorerHosts ?? DEFAULT_EXPLORER_HOSTS, + ), } : null; @@ -382,9 +436,9 @@ export function toSettlementDetailsView(intent: IntentResponse): SettlementDetai }, authorization: { status: authorizationStatus, - attemptId: latestAttempt?.attempt_id ?? null, - occurredAt: latestAttempt?.created_at ?? null, - sanitizedReason: sanitizeText(latestAttempt?.sanitized_error), + attemptId: displayedAttempt?.attempt_id ?? null, + occurredAt: displayedAttempt?.created_at ?? null, + sanitizedReason: sanitizeText(displayedAttempt?.sanitized_error), terminal: TERMINAL_AUTHORIZATION.has(authorizationStatus), }, verification, diff --git a/packages/settlement-ui/src/fixtures.ts b/packages/settlement-ui/src/fixtures.ts index 27ebc76..c47d8bf 100644 --- a/packages/settlement-ui/src/fixtures.ts +++ b/packages/settlement-ui/src/fixtures.ts @@ -14,6 +14,13 @@ 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', diff --git a/packages/settlement-ui/test/component.test.ts b/packages/settlement-ui/test/component.test.ts index ceb4480..11c7091 100644 --- a/packages/settlement-ui/test/component.test.ts +++ b/packages/settlement-ui/test/component.test.ts @@ -10,7 +10,7 @@ import { createElement } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; import { toSettlementDetailsView } from '../src/contract.js'; -import { SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; +import { FIXTURE_EXPLORER_HOSTS, SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; import { SettlementDetailsPanel } from '../src/SettlementDetails.js'; afterEach(cleanup); @@ -21,7 +21,11 @@ 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) }), + createElement(SettlementDetailsPanel, { + view: toSettlementDetailsView(scenario.intent, { + allowedExplorerHosts: FIXTURE_EXPLORER_HOSTS, + }), + }), ); } @@ -221,6 +225,19 @@ describe('verified transaction details', () => { 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(); diff --git a/packages/settlement-ui/test/contract.test.ts b/packages/settlement-ui/test/contract.test.ts index 56673aa..a0bb4dd 100644 --- a/packages/settlement-ui/test/contract.test.ts +++ b/packages/settlement-ui/test/contract.test.ts @@ -2,13 +2,14 @@ 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 { SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; +import { FIXTURE_EXPLORER_HOSTS, SETTLEMENT_SCENARIOS } from '../src/fixtures.js'; const COMMITTED = SETTLEMENT_SCENARIOS['authorized-committed']?.intent as IntentResponse; const HASH = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -21,28 +22,28 @@ function scenarioIntent(name: string): IntentResponse { describe('outbound explorer URL validation', () => { it('accepts an https link that references the displayed transaction', () => { - const result = validateExplorerUrl(`https://testnet.arcscan.io/tx/${HASH}`, HASH); - expect(result.href).toBe(`https://testnet.arcscan.io/tx/${HASH}`); + 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.io/search?tx=${HASH}`, HASH); - expect(result.href).toBe(`https://testnet.arcscan.io/search?tx=${HASH}`); + 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.io/tx/${HASH}`, 'plaintext http'], + [`http://testnet.arcscan.app/tx/${HASH}`, 'plaintext http'], [`vbscript:msgbox(${HASH})`, 'vbscript'], [`https://user:pass@evil.example/tx/${HASH}`, 'embedded credentials'], - ['https://testnet.arcscan.io/tx/0xdeadbeef', 'different transaction'], - ['https://testnet.arcscan.io/tx/', 'no transaction reference'], + ['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.io/tx/${HASH}\nlocation=1`, 'embedded newline'], + [`https://testnet.arcscan.app/tx/${HASH}\nlocation=1`, 'embedded newline'], ])('rejects %j (%s)', (candidate) => { const result = validateExplorerUrl(candidate, HASH); expect(result.href).toBeNull(); @@ -50,7 +51,7 @@ describe('outbound explorer URL validation', () => { }); it('rejects a link longer than the contract bound', () => { - const long = `https://testnet.arcscan.io/tx/${HASH}?padding=${'a'.repeat(300)}`; + const long = `https://testnet.arcscan.app/tx/${HASH}?padding=${'a'.repeat(300)}`; expect(validateExplorerUrl(long, HASH).href).toBeNull(); }); @@ -60,7 +61,38 @@ describe('outbound explorer URL validation', () => { }); it('refuses to bind a link to a malformed transaction hash', () => { - expect(validateExplorerUrl('https://testnet.arcscan.io/tx/0xabc', '0xabc').href).toBeNull(); + 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']); }); }); @@ -135,7 +167,9 @@ describe('settlement details projection', () => { }); it('verifies a committed settlement backed by authoritative Arc evidence', () => { - const view = toSettlementDetailsView(scenarioIntent('authorized-committed')); + 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'); @@ -158,7 +192,9 @@ describe('settlement details projection', () => { }); it('drops an unsafe explorer link on an otherwise verified settlement', () => { - const view = toSettlementDetailsView(scenarioIntent('hostile-explorer-link')); + 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.'); @@ -224,6 +260,89 @@ describe('settlement details projection', () => { 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');