diff --git a/CONNECTIVITY.md b/CONNECTIVITY.md
index 1cf989e3a..174948377 100644
--- a/CONNECTIVITY.md
+++ b/CONNECTIVITY.md
@@ -306,8 +306,8 @@ The app is available for a new session when hub presence is not busy
backend not yet ready for play) and there is no consent prompt, reserved peer id,
buffered handshake, or live message handler (`isAvailableForNewSessionPrompt()`).
Backend readiness is owned by `InternalBlockchainInterface.isReadyForPlay()`
-(simulator: connected; WalletConnect: a verified full-node peer, checked
-privately). The app still connects to the hub normally while a backend is not
+(simulator and Cloud Wallet: connected; WalletConnect: a verified full-node
+peer, checked privately). The app still connects to the hub normally while a backend is not
ready; it simply advertises busy, and inbound `advisory_start` /
`session_proposal` must still be declined even if the game WebSocket is live. A
consent prompt is a temporary unavailable state for inbound matchmaking even
diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md
index 8dc7c3599..b2780d9d7 100644
--- a/FRONTEND_ARCHITECTURE.md
+++ b/FRONTEND_ARCHITECTURE.md
@@ -137,9 +137,9 @@ means session obligation, walletless (`shouldReportHubBusy`), or that the active
blockchain backend is not yet ready for play (`blockchainReady === false`, folded
into `shouldReportHubBusy` / `shouldReportHubBusyPresence`). Readiness is owned by
the backend behind `InternalBlockchainInterface.isReadyForPlay()` /
-`onPlayReadinessChange()`: the simulator is ready whenever connected, while
-WalletConnect polls privately for a verified full-node peer (peer count never
-leaves the backend). The app still connects to the hub normally while a backend
+`onPlayReadinessChange()`: the simulator and Cloud Wallet are ready whenever
+connected, while WalletConnect polls privately for a verified full-node peer
+(peer count never leaves the backend). The app still connects to the hub normally while a backend
is not ready; it just advertises busy. Shell mirrors the backend's readiness into
`blockchainReadyRef` via `onPlayReadinessChange`, and a wallet disconnect clears
it (the backend can no longer vouch for readiness). The `HubConnection` uses a
@@ -455,7 +455,7 @@ are grouped under those phase-owned payloads:
| `unreadGame` | `boolean?` | Whether the Game tab has unread activity. |
| `walletAlert` | `boolean?` | Whether the Wallet tab should show an alert dot. |
| `hubAlert` | `boolean?` | Whether the Hub tab should show an alert dot. |
-| `blockchainType` | `'simulator' \| 'walletconnect'?` | Which wallet backend is active or should be reconnected. |
+| `blockchainType` | `'simulator' \| 'walletconnect' \| 'cloud'?` | Which wallet backend is active or should be reconnected. |
| `serializedGameSession` | `Uint8Array?` | Raw binary WASM game-session state via `serialize()`. |
| `gameSessionSchemaVersion` | `bigint?` | Rust-owned schema ID for `serializedGameSession`; currently `4`. Missing or mismatched IDs are unsupported and cleared before deserialization. |
| `pairingToken` | `string?` | Locally generated identity for the current peer-session/controller instance. It is persisted so pre-cradle setup or a full session resumes into the same instance, and it correlates Shell transition completion with that instance; it is not protocol authority. |
@@ -1110,13 +1110,20 @@ Shell manages wallet connections through two abstractions defined in
- **`InternalBlockchainInterface`** — the backend-specific implementation
(`RealBlockchainInterface` for WalletConnect, `FakeBlockchainInterface` for
- the simulator). Each exposes `beginConnect()`, `disconnect()`,
- `isConnected()`, `spend()`, etc.
+ the simulator, `CloudBlockchainInterface` for Cloud Wallet OAuth). Each
+ exposes `beginConnect()`, `disconnect()`, `isConnected()`, `spend()`, etc.
- **`ConnectionSetup`** — returned by `beginConnect()`. Contains a `uri` for
- the QR code and a `finalize()` promise that resolves when the wallet is
- paired. Optionally contains `fields` (a map of input descriptors) indicating
- the backend needs extra user input before connecting (e.g. the simulator's
- initial balance).
+ the QR code and a `finalize(values?)` promise that resolves when the wallet is
+ paired. Optionally contains `fields` (a `Record` of typed input descriptors,
+ each `{ type: 'string' | 'bigint', label, default }`) indicating the backend
+ needs extra user input before connecting, plus an optional `title`/
+ `description` for the setup modal. Examples: the simulator's initial balance
+ (`bigint`), and Cloud Wallet's OAuth `clientId` / API URL / UI URL (`string`).
+ Cloud Wallet sets `skipQr: true` and completes OAuth inside `finalize()` after
+ persisting the entered config via `cloudWalletConfig.ts` (kept separate from
+ the OAuth tokens in `cloudWalletAuth.ts`). All OAuth/GraphQL calls resolve the
+ client id and endpoints at call time through `getCloudWallet*` getters, so
+ UI-entered config takes effect without a rebuild.
**Design principle:** Shell must not branch on `blockchainType` for connection
logic. All differences between backends live behind the interface. A single
@@ -1125,16 +1132,23 @@ and poll interval; the rest of the flow is generic.
**Connection lifecycle:**
-1. User picks "Simulator" or "Link Wallet" → `handleConnect(bcType)`.
+1. User picks "Simulator", "Link Wallet", or "Cloud Wallet" →
+ `handleConnect(bcType)`.
2. `handleConnect` calls `iface.beginConnect(uniqueId)`, which returns a
`ConnectionSetup`.
-3. If `setup.fields` is present, Shell shows the `SimulatorSetupModal` overlay
- so the user can provide the required values, then `handleFinalize()` calls
- `setup.finalize()`.
-4. If `setup.fields` is absent (WalletConnect), Shell renders the QR code and
- immediately awaits `setup.finalize()`, which resolves when the wallet scans.
-5. After finalize resolves, `completeConnection()` activates polling and
- switches to the Hub tab.
+3. If `setup.fields` is present, Shell shows the generic `ConnectionSetupModal`
+ overlay so the user can provide the required values, then `handleFinalize(values)`
+ calls `setup.finalize(values)`. This path is used by both the simulator and
+ Cloud Wallet (the latter is `skipQr` yet still collects OAuth config first).
+4. If `setup.skipQr` is set with no fields (a restored WC/Cloud session), Shell
+ awaits `setup.finalize()` without showing a QR panel or modal.
+5. If `setup.fields` is absent and QR is required (WalletConnect pairing), Shell
+ renders the QR code and awaits `setup.finalize()`, which resolves when the
+ wallet scans.
+6. After finalize resolves, `completeConnection()` activates polling and
+ switches to the Hub tab. Connect/finalize failures are surfaced on the Choose
+ Connection screen and inside the setup modal via `connectError`, rather than
+ silently resetting the chooser.
**Auto-reconnect:** Both backends implement their own WebSocket reconnect
following the shared connection discipline described in
diff --git a/front-end/src/App.tsx b/front-end/src/App.tsx
index fa1321a22..8194324a2 100644
--- a/front-end/src/App.tsx
+++ b/front-end/src/App.tsx
@@ -1,5 +1,14 @@
import Shell from './components/Shell';
+import OAuthCallback from './components/OAuthCallback';
+import { CLOUD_WALLET_OAUTH_CALLBACK_PATH } from './constants/env';
-const App = () => ;
+function isOAuthCallbackPath(): boolean {
+ if (typeof window === 'undefined') return false;
+ const path = window.location.pathname.replace(/\/$/, '') || '/';
+ const target = CLOUD_WALLET_OAUTH_CALLBACK_PATH.replace(/\/$/, '') || '/oauth/callback';
+ return path === target || path.endsWith(target);
+}
+
+const App = () => (isOAuthCallbackPath() ? : );
export default App;
diff --git a/front-end/src/components/ConnectionSetupModal.tsx b/front-end/src/components/ConnectionSetupModal.tsx
new file mode 100644
index 000000000..b5acae19b
--- /dev/null
+++ b/front-end/src/components/ConnectionSetupModal.tsx
@@ -0,0 +1,188 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Button } from './button';
+import type { ConnectionField, ConnectionFieldValues } from '../types/ChiaGaming';
+
+interface ConnectionSetupModalProps {
+ open: boolean;
+ title?: string;
+ description?: string;
+ fields?: Record;
+ onConnect: (values: ConnectionFieldValues) => void;
+ onCancel?: () => void;
+ connecting: boolean;
+ error?: string | null;
+}
+
+export function ConnectionSetupModal({
+ open,
+ title,
+ description,
+ fields,
+ onConnect,
+ onCancel,
+ connecting,
+ error,
+}: ConnectionSetupModalProps) {
+ const panelRef = useRef(null);
+ const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(
+ null,
+ );
+ const offsetRef = useRef({ x: 0, y: 0 });
+
+ const fieldEntries = useMemo(() => (fields ? Object.entries(fields) : []), [fields]);
+ const [inputs, setInputs] = useState>({});
+
+ useEffect(() => {
+ if (!open) return;
+ const initial: Record = {};
+ for (const [key, field] of Object.entries(fields ?? {})) {
+ initial[key] = field.type === 'bigint' ? field.default.toString() : field.default;
+ }
+ setInputs(initial);
+ offsetRef.current = { x: 0, y: 0 };
+ if (panelRef.current) panelRef.current.style.transform = 'translate(-50%, -50%)';
+ // Re-initialize whenever the modal opens or the field set changes.
+ }, [open, fields]);
+
+ const clampToContainer = useCallback((x: number, y: number) => {
+ const panel = panelRef.current;
+ if (!panel) return { x, y };
+ const container = panel.offsetParent as HTMLElement | null;
+ if (!container) return { x, y };
+
+ const pw = panel.offsetWidth;
+ const ph = panel.offsetHeight;
+ const cw = container.clientWidth;
+ const ch = container.clientHeight;
+
+ const minX = pw / 2 - cw / 2;
+ const maxX = cw / 2 - pw / 2;
+ const minY = ph / 2 - ch / 2;
+ const maxY = ch / 2 - ph / 2;
+
+ return {
+ x: minX < maxX ? Math.max(minX, Math.min(maxX, x)) : 0,
+ y: minY < maxY ? Math.max(minY, Math.min(maxY, y)) : 0,
+ };
+ }, []);
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ if (!dragState.current || !panelRef.current) return;
+ e.preventDefault();
+ document.body.style.cursor = 'grabbing';
+ document.body.style.userSelect = 'none';
+ const rawX = dragState.current.origX + (e.clientX - dragState.current.startX);
+ const rawY = dragState.current.origY + (e.clientY - dragState.current.startY);
+ const { x, y } = clampToContainer(rawX, rawY);
+ offsetRef.current = { x, y };
+ panelRef.current.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
+ };
+ const onUp = () => {
+ dragState.current = null;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ return () => {
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ }, [clampToContainer]);
+
+ const handleDragStart = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ dragState.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ origX: offsetRef.current.x,
+ origY: offsetRef.current.y,
+ };
+ }, []);
+
+ const handleConnect = useCallback(() => {
+ const values: ConnectionFieldValues = {};
+ for (const [key, field] of fieldEntries) {
+ const raw = inputs[key] ?? '';
+ if (field.type === 'bigint') {
+ try {
+ values[key] = BigInt(raw.trim() === '' ? '0' : raw.trim());
+ } catch {
+ values[key] = field.default;
+ }
+ } else {
+ values[key] = raw;
+ }
+ }
+ onConnect(values);
+ }, [fieldEntries, inputs, onConnect]);
+
+ if (!open) return null;
+
+ return (
+