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 ( +
+
+

+ {title ?? 'Connect'} +

+ {description ?

{description}

: null} +
+ + {fieldEntries.length > 0 ? ( +
+ {fieldEntries.map(([key, field]) => ( + + ))} +
+ ) : null} + + {error ?

{error}

: null} + +
+ {onCancel ? ( + + ) : null} + +
+
+ ); +} diff --git a/front-end/src/components/OAuthCallback.tsx b/front-end/src/components/OAuthCallback.tsx new file mode 100644 index 000000000..b60616f2a --- /dev/null +++ b/front-end/src/components/OAuthCallback.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { handleOAuthCallbackPage } from '../hooks/cloudWalletOAuth'; + +/** + * Minimal handoff page for Cloud Wallet OAuth redirect. + * Posts the authorization code to the opener and shows a short status. + */ +export default function OAuthCallback() { + const [message, setMessage] = useState('Completing Cloud Wallet login…'); + const [ok, setOk] = useState(null); + + useEffect(() => { + const result = handleOAuthCallbackPage(); + setOk(result.status === 'ok'); + setMessage(result.message); + if (result.status === 'ok') { + const timer = setTimeout(() => { + try { + window.close(); + } catch { + // ignore + } + }, 400); + return () => clearTimeout(timer); + } + return undefined; + }, []); + + return ( +
+
+

+ {ok === false ? 'Cloud Wallet login failed' : 'Cloud Wallet'} +

+

{message}

+
+
+ ); +} diff --git a/front-end/src/components/Shell.tsx b/front-end/src/components/Shell.tsx index 8b6d94e61..77c64cb4d 100644 --- a/front-end/src/components/Shell.tsx +++ b/front-end/src/components/Shell.tsx @@ -17,7 +17,7 @@ import GameSession from './GameSession'; import { GameSessionErrorBoundary, UncaughtClientErrorReporter } from './GameSession'; import { SessionTransitionSurface } from './SessionTransitionSurface'; import FinishedSessionGameView from './FinishedSessionGameView'; -import { SimulatorSetupModal } from './SimulatorSetupModal'; +import { ConnectionSetupModal } from './ConnectionSetupModal'; import QRCode from 'qrcode'; import { GameSessionParams, @@ -93,6 +93,7 @@ import { } from '../hooks/blobSingleton'; import { fakeBlockchainInfo } from '../hooks/FakeBlockchainInterface'; import { realBlockchainInfo } from '../hooks/RealBlockchainInterface'; +import { cloudBlockchainInfo } from '../hooks/CloudBlockchainInterface'; import { activate, deactivate, getActiveBlockchain } from '../hooks/activeBlockchain'; import { BALANCE_POLL_INTERVAL_MS, @@ -157,10 +158,16 @@ import { HubPicker } from './HubPicker'; type TabId = 'wallet' | 'hub' | 'game' | 'history' | 'log'; -function getInterface(bcType: 'simulator' | 'walletconnect') { - return bcType === 'walletconnect' - ? { iface: realBlockchainInfo, pollMs: CHAIN_POLL_INTERVAL_MS } - : { iface: fakeBlockchainInfo, pollMs: 5000 }; +type ShellBlockchainType = 'simulator' | 'walletconnect' | 'cloud'; + +function getInterface(bcType: ShellBlockchainType) { + if (bcType === 'walletconnect') { + return { iface: realBlockchainInfo, pollMs: CHAIN_POLL_INTERVAL_MS }; + } + if (bcType === 'cloud') { + return { iface: cloudBlockchainInfo, pollMs: CHAIN_POLL_INTERVAL_MS }; + } + return { iface: fakeBlockchainInfo, pollMs: 5000 }; } function normalizeHubOrigin(origin: string): string { @@ -948,7 +955,8 @@ const Shell = () => { setIframeUrl('about:blank'); setHubOrigin(null); setHubLiveness(null); - if (blockchainTypeRef.current !== 'walletconnect') { + // Simulator tears down its WS; WC/Cloud keep durable auth across tab handoff. + if (blockchainTypeRef.current === 'simulator') { activeBlockchainRef.current?.disconnect().catch(() => {}); } deactivate(); @@ -979,9 +987,9 @@ const Shell = () => { if (event.persisted) return; releaseLeaseIfOwner(); hubConnRef.current?.disconnect(); - // WalletConnect sessions are intentionally durable across reloads. - // Calling disconnect() here sends a protocol-level session_delete. - if (blockchainTypeRef.current !== 'walletconnect') { + // WalletConnect / Cloud Wallet sessions are durable across reloads. + // Calling disconnect() on WC sends session_delete; on Cloud it wipes OAuth tokens. + if (blockchainTypeRef.current === 'simulator') { activeBlockchainRef.current?.disconnect().catch(() => {}); } }; @@ -1062,10 +1070,10 @@ const Shell = () => { const [iframeUrl, setIframeUrl] = useState('about:blank'); const [balance, setBalance] = useState(); - const [blockchainType, setBlockchainType] = useState<'simulator' | 'walletconnect' | undefined>( - () => getBlockchainType(), + const [blockchainType, setBlockchainType] = useState(() => + getBlockchainType(), ); - const blockchainTypeRef = useRef<'simulator' | 'walletconnect' | undefined>(blockchainType); + const blockchainTypeRef = useRef(blockchainType); // Busy bit reported to the hub: session obligation, walletless, OR the // backend not yet ready for play. All setBusy paths that might clear // availability must go through this (not bare false / phase-only busy). @@ -1084,9 +1092,10 @@ const Shell = () => { }, [blockchainType]); // Connection state - const [showSimModal, setShowSimModal] = useState(false); + const [showConnectionSetupModal, setShowConnectionSetupModal] = useState(false); const [connectionSetup, setConnectionSetup] = useState(null); const [connecting, setConnecting] = useState(false); + const [connectError, setConnectError] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(''); const wcAbortRef = useRef(false); const [defaultFee, setDefaultFee] = useState(() => getDefaultFee()); @@ -1758,7 +1767,7 @@ const Shell = () => { }, []); const startBalancePolling = useCallback( - (_bcType: 'simulator' | 'walletconnect') => { + (_bcType: ShellBlockchainType) => { stopBalancePolling(); try { getActiveBlockchain().startBalanceInterest(BALANCE_POLL_INTERVAL_MS, { @@ -2220,7 +2229,7 @@ const Shell = () => { const completeConnection = useCallback( ( iface: InternalBlockchainInterface, - bcType: 'simulator' | 'walletconnect', + bcType: ShellBlockchainType, pollMs: number, options: { switchToHub?: boolean } = {}, ) => { @@ -2270,9 +2279,10 @@ const Shell = () => { // silent: skip the modal on reconnect (e.g. auto-reconnect after completed connection) // fresh: wipe stale WC storage before connecting (user explicitly starting a new pairing) const handleConnect = useCallback( - async (bcType: 'simulator' | 'walletconnect', silent = false, fresh = false) => { + async (bcType: ShellBlockchainType, silent = false, fresh = false) => { log(`[Shell] handleConnect: bcType=${bcType} silent=${silent} fresh=${fresh}`); wcAbortRef.current = false; + if (!silent) setConnectError(null); const { iface, pollMs } = getInterface(bcType); try { markSavedSession(); @@ -2281,7 +2291,8 @@ const Shell = () => { setConnecting(true); const setup = await iface.beginConnect(uniqueId, fresh); if (wcAbortRef.current) return; - const needsWalletPairing = bcType === 'walletconnect' && !setup.skipQr && !setup.fields; + // QR pairing (WalletConnect): show QR and wait for the wallet; do not finalize yet. + const needsWalletPairing = !setup.skipQr && !setup.fields; if (needsWalletPairing) { setConnectionSetup(setup); setWalletConnected(false); @@ -2291,9 +2302,11 @@ const Shell = () => { return; } } - if (!setup.skipQr) setConnectionSetup(setup); + // Retain the setup whenever we need the user to act on it: QR pairing or + // a setup-fields modal (which may be skipQr, e.g. Cloud Wallet OAuth). + if (!setup.skipQr || setup.fields) setConnectionSetup(setup); if (setup.fields && !silent) { - setShowSimModal(true); + setShowConnectionSetupModal(true); setConnecting(false); return; } @@ -2308,10 +2321,11 @@ const Shell = () => { } catch (err) { if (!wcAbortRef.current) { console.error(`[Shell] ${bcType} connect failed`, err); + setConnectError(err instanceof Error ? err.message : String(err)); } if (silent) { - // beginConnect may have failed before completeConnection ran. - if (bcType !== 'walletconnect') { + // Simulator may still be usable offline; WC/Cloud need a real session. + if (bcType === 'simulator') { completeConnection(iface, bcType, pollMs); } else { setConnecting(false); @@ -2331,22 +2345,27 @@ const Shell = () => { [uniqueId, clearSessionPreservingHistory, completeConnection, setConnecting, setWalletAlert], ); - const handleFinalize = useCallback(async () => { - if (!connectionSetup || !blockchainType) return; - log(`[Shell] handleFinalize: bcType=${blockchainType}`); - const { iface, pollMs } = getInterface(blockchainType); - setConnecting(true); - try { - await connectionSetup.finalize(); - log(`[Shell] handleFinalize: finalize complete`); - setShowSimModal(false); - completeConnection(iface, blockchainType, pollMs, { switchToHub: true }); - } catch (err) { - console.error(`[Shell] ${blockchainType} finalize failed`, err); - } finally { - setConnecting(false); - } - }, [connectionSetup, blockchainType, completeConnection]); + const handleFinalize = useCallback( + async (values?: Record) => { + if (!connectionSetup || !blockchainType) return; + log(`[Shell] handleFinalize: bcType=${blockchainType}`); + const { iface, pollMs } = getInterface(blockchainType); + setConnectError(null); + setConnecting(true); + try { + await connectionSetup.finalize(values); + log(`[Shell] handleFinalize: finalize complete`); + setShowConnectionSetupModal(false); + completeConnection(iface, blockchainType, pollMs, { switchToHub: true }); + } catch (err) { + console.error(`[Shell] ${blockchainType} finalize failed`, err); + setConnectError(err instanceof Error ? err.message : String(err)); + } finally { + setConnecting(false); + } + }, + [connectionSetup, blockchainType, completeConnection], + ); const handleCancelConnect = useCallback(async () => { wcAbortRef.current = true; @@ -2373,7 +2392,8 @@ const Shell = () => { clearSessionPreservingHistory(); setConnecting(false); setWalletConnected(false); - setShowSimModal(false); + setShowConnectionSetupModal(false); + setConnectError(null); }, [blockchainType, clearSessionPreservingHistory, stopBalancePolling]); const onGameActivity = useCallback(() => { @@ -2860,7 +2880,8 @@ const Shell = () => { void (async () => { try { const setup = await iface.beginConnect(uniqueId); - const needsWalletPairing = bcType === 'walletconnect' && !setup.skipQr && !setup.fields; + // QR pairing (WalletConnect): show QR and wait for the wallet; do not finalize yet. + const needsWalletPairing = !setup.skipQr && !setup.fields; if (needsWalletPairing) { setConnectionSetup(setup); setWalletConnected(false); @@ -2875,7 +2896,7 @@ const Shell = () => { } catch (err) { console.warn('[Shell] performResume connect failed, falling back', err); // beginConnect may have failed before completeConnection ran. - if (!activeBlockchainRef.current && bcType !== 'walletconnect') { + if (!activeBlockchainRef.current && bcType === 'simulator') { completeConnection(iface, bcType, pollMs); } else { setConnecting(false); @@ -3759,6 +3780,16 @@ const Shell = () => { > Transaction publishing: {transactionPublishNerfed ? 'nerfed' : 'enabled'} + {walletConnected ? (
@@ -3829,7 +3860,7 @@ const Shell = () => { Disconnect
- ) : connectionSetup ? ( + ) : connectionSetup && !connectionSetup.skipQr ? (

Scan QR Code

@@ -3951,11 +3982,6 @@ const Shell = () => { -

) : connecting ? (
@@ -4025,7 +4051,19 @@ const Shell = () => { > Link Wallet +
+ {connectError ? ( +

+ {connectError} +

+ ) : null}
)} diff --git a/front-end/src/components/SimulatorSetupModal.tsx b/front-end/src/components/SimulatorSetupModal.tsx deleted file mode 100644 index 332c5d53f..000000000 --- a/front-end/src/components/SimulatorSetupModal.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { Button } from './button'; - -interface SimulatorSetupModalProps { - open: boolean; - onConnect: () => void; - connecting: boolean; -} - -export function SimulatorSetupModal({ open, onConnect, connecting }: SimulatorSetupModalProps) { - const panelRef = useRef(null); - const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>( - null, - ); - const offsetRef = useRef({ x: 0, y: 0 }); - - useEffect(() => { - if (open) { - offsetRef.current = { x: 0, y: 0 }; - if (panelRef.current) panelRef.current.style.transform = 'translate(-50%, -50%)'; - } - }, [open]); - - 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, - }; - }, []); - - if (!open) return null; - - return ( -
-
-

Simulator

-

Connect to the simulated blockchain

-
- - -
- ); -} diff --git a/front-end/src/constants/env.ts b/front-end/src/constants/env.ts index a4e28e30a..7f1ec0150 100644 --- a/front-end/src/constants/env.ts +++ b/front-end/src/constants/env.ts @@ -32,3 +32,21 @@ export const TESTNET_GENESIS_CHALLENGE = */ export const GENESIS_CHALLENGE_OVERRIDE: string | undefined = _win.__CHIA_GAMING_GENESIS_CHALLENGE__ || _env.CHIA_GAMING_GENESIS_CHALLENGE || undefined; + +/** Cloud Wallet API origin (authorize, token, graphql). */ +export const CLOUD_WALLET_API_URL: string = + _win.__CLOUD_WALLET_API_URL__ || _env.CHIA_GAMING_CLOUD_WALLET_API_URL || 'http://127.0.0.1:3001'; + +/** Cloud Wallet UI origin (consent, signature-request approve popup). */ +export const CLOUD_WALLET_UI_URL: string = + _win.__CLOUD_WALLET_UI_URL__ || _env.CHIA_GAMING_CLOUD_WALLET_UI_URL || 'http://127.0.0.1:3000'; + +/** OAuth client_id registered for Chia Gaming. */ +export const CLOUD_WALLET_CLIENT_ID: string = + _win.__CLOUD_WALLET_CLIENT_ID__ || _env.CHIA_GAMING_CLOUD_WALLET_CLIENT_ID || ''; + +/** Fixed OAuth redirect path on the gaming origin. */ +export const CLOUD_WALLET_OAUTH_CALLBACK_PATH = '/oauth/callback'; + +export const CLOUD_WALLET_OAUTH_SCOPES = + 'wallet.read transfer.create signatureRequest.submit offline_access'; diff --git a/front-end/src/hooks/CloudBlockchainInterface.ts b/front-end/src/hooks/CloudBlockchainInterface.ts new file mode 100644 index 000000000..0b562490e --- /dev/null +++ b/front-end/src/hooks/CloudBlockchainInterface.ts @@ -0,0 +1,665 @@ +import { + InternalBlockchainInterface, + BlockchainInboundAddressResult, + ConnectionSetup, +} from '../types/ChiaGaming'; +import { CoinRecord } from '../types/rpc/CoinRecord'; +import { WalletSpendBundle } from '../types/rpc/PushTransactions'; +import { log } from '../services/log'; +import { normalizeHexString, toUint8, toHexString } from '../util'; +import { + beginOAuthPopupLogin, + createAuthTokenProvider, + graphqlRequest, + normalizeHex, + signatureRequestApproveUrl, + SIGNATURE_REQUEST_MESSAGE_TYPE, + type TokenProvider, +} from './cloudWalletOAuth'; +import { + getCloudWalletApiUrl, + getCloudWalletClientId, + getCloudWalletUiUrl, + loadCloudWalletConfig, + saveCloudWalletConfig, +} from './cloudWalletConfig'; +import { + clearCloudWalletAuth, + loadCloudWalletAuth, + saveCloudWalletAuth, + type CloudWalletAuthState, +} from './cloudWalletAuth'; +import { + absAmountFromOffer, + coinSpendsToWalletBundle, + conditionsForGraphql, + jsonSafeVariables, + selectCoinStringForAmount, +} from './cloudWalletHelpers'; + +export { + absAmountFromOffer, + coinSpendsToWalletBundle, + conditionsForGraphql, + jsonSafeVariables, + selectCoinStringForAmount, +} from './cloudWalletHelpers'; + +const APPROVE_TIMEOUT_MS = 10 * 60 * 1000; +const SR_POLL_MS = 1500; + +export class CloudBlockchainInterface implements InternalBlockchainInterface { + blockchainAddressData: BlockchainInboundAddressResult = { puzzleHash: '' }; + + private auth: CloudWalletAuthState | null = null; + private connectionListeners = new Set<(connected: boolean) => void>(); + private readinessListeners = new Set<(ready: boolean) => void>(); + private lastConnectedState = false; + private monitoringReady = false; + private tokenProvider: TokenProvider; + + constructor() { + this.auth = loadCloudWalletAuth(); + this.tokenProvider = createAuthTokenProvider( + () => this.auth, + (next) => { + this.auth = next; + saveCloudWalletAuth(next); + }, + ); + } + + private requireWalletId(): string { + if (!this.auth?.walletId) { + throw new Error('Cloud Wallet walletId is not set'); + } + return this.auth.walletId; + } + + private async gql(query: string, variables?: Record): Promise { + const safe = variables ? (jsonSafeVariables(variables) as Record) : undefined; + return graphqlRequest(query, safe, this.tokenProvider); + } + + private fireConnectionChange(connected: boolean) { + if (this.lastConnectedState === connected) return; + this.lastConnectedState = connected; + for (const cb of this.connectionListeners) { + try { + cb(connected); + } catch { + // ignore + } + } + // Cloud Wallet has no full-node peer wait: play readiness tracks connectivity. + for (const cb of this.readinessListeners) { + try { + cb(connected); + } catch { + // ignore + } + } + } + + private persistAuth( + partial: Partial & + Pick, + ) { + const walletId = partial.walletId ?? this.auth?.walletId ?? ''; + if (!walletId) { + throw new Error('Cloud Wallet walletId is required before persisting auth'); + } + this.auth = { + accessToken: partial.accessToken, + refreshToken: partial.refreshToken, + expiresAt: partial.expiresAt, + walletId, + }; + saveCloudWalletAuth(this.auth); + } + + private async resolveWalletId(): Promise { + const stored = this.auth?.walletId; + if (stored) { + try { + const data = await this.gql<{ wallet: { id: string } | null }>( + `query($id: ID!) { wallet(id: $id) { id } }`, + { id: stored }, + ); + if (data.wallet?.id) return data.wallet.id; + } catch (e) { + log(`[cloud-blockchain] stored walletId not readable: ${String(e)}`); + } + } + + throw new Error( + 'No Cloud Wallet walletId available. Reconnect and ensure OAuth consent selects a wallet resource.', + ); + } + + async getAddress(): Promise { + return this.blockchainAddressData; + } + + async startMonitoring(): Promise { + const walletId = this.requireWalletId(); + const data = await this.gql<{ + wallet: { + id: string; + address: { puzzleHash: string } | null; + } | null; + }>( + `query($id: ID!) { + wallet(id: $id) { + id + address { puzzleHash } + } + }`, + { id: walletId }, + ); + if (!data.wallet) { + throw new Error('Cloud Wallet wallet not found'); + } + const ph = normalizeHex(data.wallet.address?.puzzleHash); + if (!ph || ph.length !== 64) { + throw new Error(`Cloud Wallet wallet has no address puzzle hash (walletId=${walletId})`); + } + this.blockchainAddressData = { puzzleHash: ph }; + this.monitoringReady = true; + this.fireConnectionChange(true); + log(`[cloud-blockchain] monitoring ready wallet=${walletId} ph=${ph}`); + } + + async getBalance(): Promise { + const walletId = this.requireWalletId(); + const data = await this.gql<{ + wallet: { balance: string | number | bigint } | null; + }>(`query($id: ID!) { wallet(id: $id) { balance } }`, { id: walletId }); + if (!data.wallet || data.wallet.balance == null) { + throw new Error('Cloud Wallet balance unavailable'); + } + return BigInt(data.wallet.balance); + } + + async selectCoins(_uniqueId: string, amount: bigint): Promise { + const walletId = this.requireWalletId(); + const data = await this.gql<{ + coins: { + edges: Array<{ + node: { + name: string; + amount: string | number | bigint; + puzzleHash: string; + parentCoinName?: string; + parentCoinInfo?: string; + }; + }>; + }; + }>( + `query($walletId: ID!, $first: Int!) { + coins(walletId: $walletId, first: $first) { + edges { + node { + name + amount + puzzleHash + } + } + } + }`, + { walletId, first: 50 }, + ); + + const nodes = data.coins?.edges?.map((e) => e.node) ?? []; + // coins connection may not expose parentCoinInfo; resolve via coinRecordsByNames. + const names = nodes.map((n) => normalizeHex(n.name)).filter((n) => n.length === 64); + if (names.length === 0) return null; + + const records = await this.getCoinRecordsByNames(names); + const unspent = records + .filter((r) => !r.spent) + .map((r) => ({ + parentCoinInfo: normalizeHexString(r.coin.parentCoinInfo), + puzzleHash: normalizeHexString(r.coin.puzzleHash), + amount: r.coin.amount, + })); + const coinString = selectCoinStringForAmount(unspent, amount); + if (!coinString) { + log(`[cloud-blockchain] selectCoins: no coin >= ${amount}`); + return null; + } + log(`[cloud-blockchain] selectCoins amount=${amount} coinStringLen=${coinString.length}`); + return coinString; + } + + async getHeightInfo(): Promise { + const data = await this.gql<{ + blockchainHeight: { height: number | string | bigint }; + }>(`query { blockchainHeight { height } }`); + if (data.blockchainHeight?.height == null) { + throw new Error('blockchainHeight missing height'); + } + return BigInt(data.blockchainHeight.height); + } + + async getPuzzleAndSolution(coin: string): Promise { + try { + const coinBytes = toUint8(coin); + const hashBuf = await crypto.subtle.digest('SHA-256', coinBytes); + const coinName = toHexString(new Uint8Array(hashBuf)); + const walletId = this.requireWalletId(); + const data = await this.gql<{ + puzzleAndSolution: { puzzleReveal: string; solution: string } | null; + }>( + `query($walletId: ID!, $coinId: String!) { + puzzleAndSolution(walletId: $walletId, coinId: $coinId) { + puzzleReveal + solution + } + }`, + { walletId, coinId: coinName }, + ); + const payload = data.puzzleAndSolution; + if (!payload?.puzzleReveal || !payload?.solution) return null; + return [normalizeHex(payload.puzzleReveal), normalizeHex(payload.solution)]; + } catch (e) { + log(`[cloud-blockchain] getPuzzleAndSolution error: ${String(e)}`); + return null; + } + } + + async getCoinRecordsByNames(names: string[]): Promise { + const uniqueNames = [...new Set(names.map((n) => normalizeHex(n)).filter(Boolean))]; + if (uniqueNames.length === 0) return []; + const walletId = this.requireWalletId(); + try { + const data = await this.gql<{ + coinRecordsByNames: Array<{ + name: string; + amount: string | number | bigint; + puzzleHash: string; + parentCoinName?: string; + createdBlockHeight?: number | null; + spentBlockHeight?: number | null; + }>; + }>( + `query($walletId: ID!, $names: [String!]!) { + coinRecordsByNames(walletId: $walletId, names: $names) { + name + amount + puzzleHash + parentCoinName + createdBlockHeight + spentBlockHeight + } + }`, + { walletId, names: uniqueNames }, + ); + + return (data.coinRecordsByNames ?? []).map((r) => { + const spentHeight = r.spentBlockHeight == null ? 0n : BigInt(r.spentBlockHeight); + const confirmed = r.createdBlockHeight == null ? 0n : BigInt(r.createdBlockHeight); + // parentCoinName may be the parent coin id; CoinRecord expects parentCoinInfo. + const parent = normalizeHex(r.parentCoinName); + return { + coin: { + parentCoinInfo: parent || '0'.repeat(64), + puzzleHash: normalizeHex(r.puzzleHash), + amount: BigInt(r.amount), + }, + confirmedBlockIndex: confirmed, + spentBlockIndex: spentHeight, + spent: spentHeight > 0n, + coinbase: false, + timestamp: 0n, + }; + }); + } catch (e) { + log(`[cloud-blockchain] getCoinRecordsByNames error: ${String(e)}`); + return []; + } + } + + async registerCoins(_names: string[]): Promise { + // Cloud indexing replaces remote-wallet registration. + } + + async rememberLocalRemovals(_spendBundle: unknown): Promise { + // No WC pushTransactions metadata needed for Cloud broadcast. + } + + async spend( + _blob: string, + spendBundle: unknown, + _changePuzzleHash: string, + source?: string, + fee?: bigint, + ): Promise { + const feeValue = fee || 0n; + if (feeValue !== 0n) { + throw new Error('Cloud Wallet v1 does not support nonzero external fees'); + } + const walletId = this.requireWalletId(); + const bundle = spendBundle as WalletSpendBundle; + if (!bundle?.coin_spends?.length) { + throw new Error('broadcastSpendBundle: empty spend bundle'); + } + + const data = await this.gql<{ broadcastSpendBundle: { status: string } }>( + `mutation($input: BroadcastSpendBundleInput!) { + broadcastSpendBundle(input: $input) { status } + }`, + { + input: { + walletId, + aggregatedSignature: normalizeHex(bundle.aggregated_signature), + coinSpends: bundle.coin_spends.map((cs) => ({ + coin: { + parentCoinInfo: normalizeHex(cs.coin.parent_coin_info), + puzzleHash: normalizeHex(cs.coin.puzzle_hash), + amount: cs.coin.amount, + }, + puzzleReveal: normalizeHex(cs.puzzle_reveal), + solution: normalizeHex(cs.solution), + })), + }, + }, + ); + const status = data.broadcastSpendBundle?.status ?? 'unknown'; + log(`[cloud-blockchain] broadcastSpendBundle from=${source ?? 'unknown'} status=${status}`); + return status; + } + + private openApprovePopup(signatureRequestId: string): Window | null { + const url = signatureRequestApproveUrl(signatureRequestId); + const width = 520; + const height = 720; + const left = Math.max(0, Math.floor(window.screenX + (window.outerWidth - width) / 2)); + const top = Math.max(0, Math.floor(window.screenY + (window.outerHeight - height) / 2)); + return window.open( + url, + 'chia-gaming-cloud-wallet-approve', + `popup=yes,width=${width},height=${height},left=${left},top=${top}`, + ); + } + + private waitForSignatureApproval(signatureRequestId: string): Promise<'approved'> { + const uiOrigin = new URL(getCloudWalletUiUrl()).origin; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + window.removeEventListener('message', onMessage); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => reject(new Error('Timed out waiting for Cloud Wallet funding approval'))); + }, APPROVE_TIMEOUT_MS); + + const onMessage = (event: MessageEvent) => { + if (event.origin !== uiOrigin) return; + const data = event.data; + if (!data || data.type !== SIGNATURE_REQUEST_MESSAGE_TYPE) return; + const msgId = String(data.signatureRequestId ?? ''); + if ( + msgId && + msgId !== signatureRequestId && + !signatureRequestId.endsWith(msgId) && + !msgId.endsWith(signatureRequestId) + ) { + return; + } + if (data.status === 'approved') { + finish(() => resolve('approved')); + return; + } + if (data.status === 'rejected') { + finish(() => reject(new Error('Cloud Wallet funding approval was rejected'))); + return; + } + if (data.status === 'error') { + finish(() => reject(new Error(data.message || 'Cloud Wallet funding approval failed'))); + } + }; + + window.addEventListener('message', onMessage); + }); + } + + private async pollSignatureRequestSigned(signatureRequestId: string): Promise { + const started = Date.now(); + while (Date.now() - started < APPROVE_TIMEOUT_MS) { + const data = await this.gql<{ + signatureRequest: { + id: string; + status: string; + coinSpends: any[] | null; + } | null; + }>( + `query($id: ID!) { + signatureRequest(id: $id) { + id + status + coinSpends { + coin { parentCoinInfo puzzleHash amount } + puzzleReveal + solution + } + } + }`, + { id: signatureRequestId }, + ); + const sr = data.signatureRequest; + if (!sr) { + throw new Error('signatureRequest not found'); + } + const status = sr.status; + if (status === 'SIGNED' || status === 'SUBMITTED' || status === 'PROCESSING') { + return sr; + } + if (status === 'CANCELLED') { + throw new Error('Cloud Wallet signature request was cancelled'); + } + await new Promise((r) => setTimeout(r, SR_POLL_MS)); + } + throw new Error('Timed out polling Cloud Wallet signature request'); + } + + async createOfferForIds( + _uniqueId: string, + offer: { [walletId: string]: bigint }, + extraConditions?: Array<{ opcode: bigint; args: string[] }>, + coinIds?: string[], + maxHeight?: bigint, + ): Promise { + const walletId = this.requireWalletId(); + const amount = absAmountFromOffer(offer); + const conditions = conditionsForGraphql(extraConditions, maxHeight); + + log( + `[cloud-blockchain] createGamingFundingSpend amount=${amount} conditions=${conditions.length}`, + ); + + const created = await this.gql<{ + createGamingFundingSpend: { + signatureRequest: { id: string; status: string }; + }; + }>( + `mutation($input: CreateGamingFundingSpendInput!) { + createGamingFundingSpend(input: $input) { + signatureRequest { id status } + } + }`, + { + input: { + walletId, + amount, + coinIds: coinIds?.map((id) => normalizeHex(id)), + extraConditions: conditions.length ? conditions : undefined, + autoSubmit: false, + }, + }, + ); + + const srId = created.createGamingFundingSpend?.signatureRequest?.id; + if (!srId) { + throw new Error('createGamingFundingSpend did not return a signatureRequest'); + } + + const popup = this.openApprovePopup(srId); + if (!popup) { + throw new Error('Popup blocked — allow popups to approve Cloud Wallet funding'); + } + + // Poll until SIGNED; fail fast on postMessage rejected/error (ignore message timeout). + const approvalFailure = new Promise((_resolve, reject) => { + void this.waitForSignatureApproval(srId).catch((e: unknown) => { + const err = e instanceof Error ? e : new Error(String(e)); + if (!/timed out/i.test(err.message)) { + reject(err); + } + }); + }); + + let sr: any; + try { + sr = await Promise.race([this.pollSignatureRequestSigned(srId), approvalFailure]); + } finally { + try { + popup.close(); + } catch { + // ignore + } + } + + const coinSpends = sr.coinSpends; + if (!Array.isArray(coinSpends) || coinSpends.length === 0) { + throw new Error( + 'Cloud Wallet signature request is signed but returned no coinSpends. Vault-less wallets may need a Cloud Wallet API fix.', + ); + } + + const bundle = coinSpendsToWalletBundle(coinSpends); + // Attach a synthetic name for logging / WC parity. + const nameBytes = new TextEncoder().encode(JSON.stringify(bundle)); + const hashBuf = await crypto.subtle.digest('SHA-256', nameBytes); + const name = toHexString(new Uint8Array(hashBuf)); + log( + `[cloud-blockchain] createOfferForIds signed bundle name=${name} spends=${bundle.coin_spends.length}`, + ); + return bundle; + } + + async beginConnect(_uniqueId: string, fresh = false): Promise { + if (fresh) { + clearCloudWalletAuth(); + this.auth = null; + this.monitoringReady = false; + this.fireConnectionChange(false); + } + + const existing = loadCloudWalletAuth(); + if (existing && !fresh) { + this.auth = existing; + return { + qrUri: 'cloud-wallet://session', + skipQr: true, + finalize: async () => { + try { + // Refresh if needed via token provider, then resolve wallet + monitor. + await this.tokenProvider.getAccessToken(); + const walletId = await this.resolveWalletId(); + this.persistAuth({ ...this.auth!, walletId }); + await this.startMonitoring(); + } catch (e) { + clearCloudWalletAuth(); + this.auth = null; + this.monitoringReady = false; + this.fireConnectionChange(false); + throw e; + } + }, + }; + } + + const stored = loadCloudWalletConfig(); + return { + qrUri: 'cloud-wallet://oauth', + skipQr: true, + title: 'Cloud Wallet', + description: 'Enter your Cloud Wallet OAuth settings, then sign in via the popup.', + fields: { + clientId: { + type: 'string', + label: 'OAuth client ID', + default: stored?.clientId ?? getCloudWalletClientId(), + }, + apiUrl: { + type: 'string', + label: 'Cloud Wallet API URL', + default: getCloudWalletApiUrl(), + }, + uiUrl: { + type: 'string', + label: 'Cloud Wallet UI URL', + default: getCloudWalletUiUrl(), + }, + }, + finalize: async (values?: Record) => { + const clientId = String(values?.clientId ?? getCloudWalletClientId()).trim(); + const apiUrl = String(values?.apiUrl ?? getCloudWalletApiUrl()).trim(); + const uiUrl = String(values?.uiUrl ?? getCloudWalletUiUrl()).trim(); + if (!clientId) { + throw new Error('Cloud Wallet OAuth client ID is required'); + } + saveCloudWalletConfig({ clientId, apiUrl, uiUrl }); + + const tokens = await beginOAuthPopupLogin(); + this.auth = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + walletId: tokens.walletId, + }; + await this.resolveWalletId(); + this.persistAuth(tokens); + await this.startMonitoring(); + }, + }; + } + + async disconnect(): Promise { + clearCloudWalletAuth(); + this.auth = null; + this.monitoringReady = false; + this.blockchainAddressData = { puzzleHash: '' }; + this.fireConnectionChange(false); + } + + isConnected(): boolean { + return this.monitoringReady && !!this.auth?.walletId; + } + + onConnectionChange(cb: (connected: boolean) => void): () => void { + this.connectionListeners.add(cb); + return () => { + this.connectionListeners.delete(cb); + }; + } + + isReadyForPlay(): boolean { + return this.lastConnectedState; + } + + onPlayReadinessChange(cb: (ready: boolean) => void): () => void { + this.readinessListeners.add(cb); + return () => { + this.readinessListeners.delete(cb); + }; + } +} + +export const cloudBlockchainInfo = new CloudBlockchainInterface(); diff --git a/front-end/src/hooks/FakeBlockchainInterface.ts b/front-end/src/hooks/FakeBlockchainInterface.ts index a6f004986..000f4fa18 100644 --- a/front-end/src/hooks/FakeBlockchainInterface.ts +++ b/front-end/src/hooks/FakeBlockchainInterface.ts @@ -378,16 +378,19 @@ export class FakeBlockchainInterface implements InternalBlockchainInterface { async beginConnect(uniqueId: string, _fresh = false): Promise { return { qrUri: `sim://${this.wsUrl.replace('ws://', '')}/${uniqueId}`, + title: 'Simulator', + description: 'Connect to the simulated blockchain', fields: { balance: { + type: 'bigint', label: `Starting balance (${getCurrencyLabels().mojos})`, default: 1_000_000n, }, }, - finalize: async (values?: { balance?: bigint }) => { + finalize: async (values?: Record) => { log('[sim-blockchain] finalize: start'); this.uniqueId = uniqueId; - this.initialBalance = values?.balance; + this.initialBalance = values?.balance === undefined ? undefined : BigInt(values.balance); this.deleted = false; this.autoReconnect = true; this.reconnectAttempt = 0; diff --git a/front-end/src/hooks/cloudWalletAuth.ts b/front-end/src/hooks/cloudWalletAuth.ts new file mode 100644 index 000000000..5bd9d691d --- /dev/null +++ b/front-end/src/hooks/cloudWalletAuth.ts @@ -0,0 +1,98 @@ +/** + * Persist Cloud Wallet OAuth tokens and selected walletId outside WalletConnect storage. + */ + +const STORAGE_KEY = 'appState_cloudWalletAuth'; + +export interface CloudWalletAuthState { + accessToken: string; + refreshToken: string; + expiresAt: number; + walletId: string; +} + +export function loadCloudWalletAuth(): CloudWalletAuthState | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.accessToken !== 'string' || + typeof parsed.refreshToken !== 'string' || + typeof parsed.expiresAt !== 'number' || + typeof parsed.walletId !== 'string' || + !parsed.accessToken || + !parsed.refreshToken || + !parsed.walletId + ) { + return null; + } + return { + accessToken: parsed.accessToken, + refreshToken: parsed.refreshToken, + expiresAt: parsed.expiresAt, + walletId: parsed.walletId, + }; + } catch { + return null; + } +} + +export function saveCloudWalletAuth(state: CloudWalletAuthState): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export function clearCloudWalletAuth(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + try { + sessionStorage.removeItem(OAUTH_PENDING_KEY); + } catch { + // ignore + } +} + +const OAUTH_PENDING_KEY = 'appState_cloudWalletOAuthPending'; + +export interface CloudWalletOAuthPending { + state: string; + codeVerifier: string; + createdAtMs: number; +} + +export function saveOAuthPending(pending: CloudWalletOAuthPending): void { + sessionStorage.setItem(OAUTH_PENDING_KEY, JSON.stringify(pending)); +} + +export function loadOAuthPending(): CloudWalletOAuthPending | null { + try { + const raw = sessionStorage.getItem(OAUTH_PENDING_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.state !== 'string' || + typeof parsed.codeVerifier !== 'string' || + typeof parsed.createdAtMs !== 'number' + ) { + return null; + } + return { + state: parsed.state, + codeVerifier: parsed.codeVerifier, + createdAtMs: parsed.createdAtMs, + }; + } catch { + return null; + } +} + +export function clearOAuthPending(): void { + try { + sessionStorage.removeItem(OAUTH_PENDING_KEY); + } catch { + // ignore + } +} diff --git a/front-end/src/hooks/cloudWalletConfig.ts b/front-end/src/hooks/cloudWalletConfig.ts new file mode 100644 index 000000000..d631713fd --- /dev/null +++ b/front-end/src/hooks/cloudWalletConfig.ts @@ -0,0 +1,72 @@ +/** + * User-editable Cloud Wallet connection config (OAuth client id + endpoints). + * + * Persisted separately from auth tokens so the whole OAuth flow can be set up + * from the player UI. Values are resolved at call time in the following order: + * persisted value -> window.__CLOUD_WALLET_* / process.env (via constants/env) + * -> hardcoded default. + */ +import { + CLOUD_WALLET_API_URL, + CLOUD_WALLET_CLIENT_ID, + CLOUD_WALLET_UI_URL, +} from '../constants/env'; + +const STORAGE_KEY = 'appState_cloudWalletConfig'; + +export interface CloudWalletConfig { + clientId: string; + apiUrl: string; + uiUrl: string; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ''); +} + +export function loadCloudWalletConfig(): Partial | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + const out: Partial = {}; + if (typeof parsed.clientId === 'string') out.clientId = parsed.clientId; + if (typeof parsed.apiUrl === 'string') out.apiUrl = parsed.apiUrl; + if (typeof parsed.uiUrl === 'string') out.uiUrl = parsed.uiUrl; + return out; + } catch { + return null; + } +} + +export function saveCloudWalletConfig(config: CloudWalletConfig): void { + const normalized: CloudWalletConfig = { + clientId: config.clientId.trim(), + apiUrl: stripTrailingSlash(config.apiUrl.trim()), + uiUrl: stripTrailingSlash(config.uiUrl.trim()), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); +} + +export function clearCloudWalletConfig(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } +} + +export function getCloudWalletClientId(): string { + const stored = loadCloudWalletConfig(); + return (stored?.clientId || CLOUD_WALLET_CLIENT_ID || '').trim(); +} + +export function getCloudWalletApiUrl(): string { + const stored = loadCloudWalletConfig(); + return stripTrailingSlash((stored?.apiUrl || CLOUD_WALLET_API_URL).trim()); +} + +export function getCloudWalletUiUrl(): string { + const stored = loadCloudWalletConfig(); + return stripTrailingSlash((stored?.uiUrl || CLOUD_WALLET_UI_URL).trim()); +} diff --git a/front-end/src/hooks/cloudWalletHelpers.ts b/front-end/src/hooks/cloudWalletHelpers.ts new file mode 100644 index 000000000..c84dc7175 --- /dev/null +++ b/front-end/src/hooks/cloudWalletHelpers.ts @@ -0,0 +1,99 @@ +import { encodeU64AsClvmHex } from '../util'; +import { BLS_NIL_SIGNATURE, normalizeHex, with0x } from './cloudWalletOAuth'; +import { WalletSpendBundle } from '../types/rpc/PushTransactions'; + +/** JSON-safe GraphQL variables (BigInt → decimal string). */ +export function jsonSafeVariables(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(jsonSafeVariables); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = jsonSafeVariables(v); + } + return out; + } + return value; +} + +export function conditionsForGraphql( + extraConditions: Array<{ opcode: bigint; args: string[] }> | undefined, + maxHeight: bigint | undefined, +): Array<{ opcode: string; args: string[] }> { + const out: Array<{ opcode: string; args: string[] }> = []; + for (const c of extraConditions ?? []) { + out.push({ + opcode: c.opcode.toString(), + args: (c.args ?? []).map((a) => String(a)), + }); + } + if (maxHeight !== undefined) { + out.push({ + opcode: '87', + args: [encodeU64AsClvmHex(maxHeight)], + }); + } + return out; +} + +export function selectCoinStringForAmount( + coins: Array<{ + name?: string; + parentCoinInfo?: string; + puzzleHash?: string; + amount?: string | number | bigint; + }>, + amount: bigint, +): string | null { + const sorted = [...coins].sort((a, b) => { + const aa = BigInt(a.amount ?? 0); + const bb = BigInt(b.amount ?? 0); + if (aa < bb) return -1; + if (aa > bb) return 1; + return 0; + }); + const selected = sorted.find((c) => BigInt(c.amount ?? 0) >= amount) ?? null; + if (!selected) return null; + + const parent = normalizeHex(selected.parentCoinInfo); + const ph = normalizeHex(selected.puzzleHash); + const amt = BigInt(selected.amount ?? 0); + if (parent && ph && parent.length === 64 && ph.length === 64) { + return `${parent}${ph}${encodeU64AsClvmHex(amt)}`; + } + return null; +} + +function byteaToHex(value: unknown): string { + return normalizeHex(value); +} + +export function coinSpendsToWalletBundle( + coinSpends: any[], + aggregatedSignature?: string | null, +): WalletSpendBundle { + const coin_spends = coinSpends.map((cs) => { + const coin = cs.coin ?? {}; + return { + coin: { + parent_coin_info: with0x(byteaToHex(coin.parentCoinInfo ?? coin.parent_coin_info)), + puzzle_hash: with0x(byteaToHex(coin.puzzleHash ?? coin.puzzle_hash)), + amount: BigInt(coin.amount ?? 0), + }, + puzzle_reveal: with0x(byteaToHex(cs.puzzleReveal ?? cs.puzzle_reveal)), + solution: with0x(byteaToHex(cs.solution)), + }; + }); + return { + coin_spends, + aggregated_signature: aggregatedSignature ? with0x(aggregatedSignature) : BLS_NIL_SIGNATURE, + }; +} + +export function absAmountFromOffer(offer: { [walletId: string]: bigint }): bigint { + const raw = offer['1'] ?? Object.values(offer)[0]; + if (raw === undefined) { + throw new Error('createOfferForIds: offer missing amount'); + } + return raw < 0n ? -raw : raw; +} diff --git a/front-end/src/hooks/cloudWalletOAuth.ts b/front-end/src/hooks/cloudWalletOAuth.ts new file mode 100644 index 000000000..7cf52485f --- /dev/null +++ b/front-end/src/hooks/cloudWalletOAuth.ts @@ -0,0 +1,600 @@ +import { CLOUD_WALLET_OAUTH_CALLBACK_PATH, CLOUD_WALLET_OAUTH_SCOPES } from '../constants/env'; +import { + getCloudWalletApiUrl, + getCloudWalletClientId, + getCloudWalletUiUrl, +} from './cloudWalletConfig'; +import { + clearOAuthPending, + loadOAuthPending, + saveOAuthPending, + type CloudWalletAuthState, +} from './cloudWalletAuth'; +import { log } from '../services/log'; + +export const OAUTH_MESSAGE_TYPE = 'chia-gaming/oauth'; +export const SIGNATURE_REQUEST_MESSAGE_TYPE = 'chia-cloud-wallet/signature-request'; +export const GAMING_CONSENT_MESSAGE_TYPE = 'chia-cloud-wallet/consent'; + +/** BLS G2 infinity / NIL aggregate signature (96 bytes). */ +export const BLS_NIL_SIGNATURE = + '0xc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + +function toBase64Url(bytes: ArrayBuffer | Uint8Array): string { + const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (let i = 0; i < arr.length; i++) { + binary += String.fromCharCode(arr[i]!); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function randomUrlSafe(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return toBase64Url(bytes); +} + +export async function createPkceChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return toBase64Url(digest); +} + +export function oauthRedirectUri(origin = window.location.origin): string { + return `${origin.replace(/\/$/, '')}${CLOUD_WALLET_OAUTH_CALLBACK_PATH}`; +} + +export function buildAuthorizeUrl(opts: { + clientId: string; + redirectUri: string; + scope: string; + state: string; + codeChallenge: string; + apiBase?: string; +}): string { + const apiBase = (opts.apiBase ?? getCloudWalletApiUrl()).replace(/\/$/, ''); + const url = new URL(`${apiBase}/authorize`); + url.searchParams.set('client_id', opts.clientId); + url.searchParams.set('scope', opts.scope); + url.searchParams.set('redirect_uri', opts.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', opts.state); + url.searchParams.set('code_challenge', opts.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + // Ask the Cloud Wallet consent screen to post the selected walletId back to this opener. + url.searchParams.set('chia_gaming_client', 'true'); + return url.toString(); +} + +export function signatureRequestApproveUrl( + signatureRequestId: string, + uiBase = getCloudWalletUiUrl(), +): string { + const base = uiBase.replace(/\/$/, ''); + const id = signatureRequestId.startsWith('SignatureRequest_') + ? signatureRequestId + : signatureRequestId.includes(':') || signatureRequestId.includes('_') + ? signatureRequestId + : `SignatureRequest_${signatureRequestId}`; + // Cloud Wallet route uses the Relay global id segment. + return `${base}/signature-requests/${encodeURIComponent(id)}`; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; +} + +async function postToken(body: Record): Promise { + const apiBase = getCloudWalletApiUrl().replace(/\/$/, ''); + const res = await fetch(`${apiBase}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body).toString(), + }); + const text = await res.text(); + let json: any; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Cloud Wallet token endpoint returned non-JSON (${res.status})`); + } + if (!res.ok || !json.access_token) { + const msg = + json.error_description || + json.error || + json.message || + `token exchange failed (${res.status})`; + throw new Error(String(msg)); + } + return json as TokenResponse; +} + +export async function exchangeAuthorizationCode(opts: { + code: string; + codeVerifier: string; + redirectUri: string; + clientId?: string; +}): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> { + const clientId = opts.clientId || getCloudWalletClientId(); + if (!clientId) { + throw new Error('CLOUD_WALLET_CLIENT_ID is not configured'); + } + const token = await postToken({ + grant_type: 'authorization_code', + client_id: clientId, + code: opts.code, + redirect_uri: opts.redirectUri, + code_verifier: opts.codeVerifier, + }); + if (!token.refresh_token) { + throw new Error('Cloud Wallet token response missing refresh_token (request offline_access)'); + } + const expiresIn = typeof token.expires_in === 'number' ? token.expires_in : 3600; + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: Date.now() + expiresIn * 1000, + }; +} + +export async function refreshAccessToken( + refreshToken: string, + clientId = getCloudWalletClientId(), +): Promise<{ + accessToken: string; + refreshToken: string; + expiresAt: number; +}> { + if (!clientId) { + throw new Error('CLOUD_WALLET_CLIENT_ID is not configured'); + } + const token = await postToken({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + }); + const expiresIn = typeof token.expires_in === 'number' ? token.expires_in : 3600; + return { + accessToken: token.access_token, + refreshToken: token.refresh_token || refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +} + +export function openOAuthPopup(authorizeUrl: string): Window | null { + const width = 480; + const height = 720; + const left = Math.max(0, Math.floor(window.screenX + (window.outerWidth - width) / 2)); + const top = Math.max(0, Math.floor(window.screenY + (window.outerHeight - height) / 2)); + return window.open( + authorizeUrl, + 'chia-gaming-cloud-wallet-oauth', + `popup=yes,width=${width},height=${height},left=${left},top=${top}`, + ); +} + +let warnedConsentOriginMismatch = false; + +/** Log once (dev) when a consent-shaped message arrives from an unexpected origin. */ +function warnConsentOriginMismatchOnce(actual: string, expected: string) { + if (warnedConsentOriginMismatch) return; + warnedConsentOriginMismatch = true; + log( + `[cloud-wallet] ignoring consent message from unexpected origin ${actual} (expected ${expected}); check CLOUD_WALLET_UI_URL`, + ); +} + +/** + * Wait for the Cloud Wallet consent screen to post the selected walletId to this opener. + * + * The consent message can race with the OAuth code redirect: Cloud Wallet may + * post the walletId just before the popup navigates or closes. Rather than + * resolving `undefined` the instant the popup closes (or the auth code arrives), + * we start a short grace period and only give up if no consent message lands + * within it. A late message during the grace period still resolves normally. + * + * Returns a handle so the caller can start the grace period once it has the auth + * code (`notifyCodeReceived`), in addition to the popup-close trigger handled here. + */ +export function waitForGamingConsentWalletId( + popup: Window | null, + timeoutMs = 5 * 60 * 1000, + graceMs = 700, +): { promise: Promise; notifyCodeReceived: () => void } { + let startGrace = () => {}; + const uiOrigin = new URL(getCloudWalletUiUrl()).origin; + + const promise = new Promise((resolve) => { + let settled = false; + let graceTimer: ReturnType | null = null; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (graceTimer) clearTimeout(graceTimer); + clearInterval(closePoll); + window.removeEventListener('message', onMessage); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => resolve(undefined)); + }, timeoutMs); + + startGrace = () => { + if (settled || graceTimer) return; + // Stop polling for popup close; a late consent message can still resolve + // during the grace window via onMessage. + clearInterval(closePoll); + graceTimer = setTimeout(() => { + finish(() => resolve(undefined)); + }, graceMs); + }; + + const onMessage = (event: MessageEvent) => { + const data = event.data; + const looksLikeConsent = !!data && data.type === GAMING_CONSENT_MESSAGE_TYPE; + if (event.origin !== uiOrigin) { + if (looksLikeConsent) warnConsentOriginMismatchOnce(event.origin, uiOrigin); + return; + } + if (!looksLikeConsent) return; + if (typeof data.walletId !== 'string') return; + finish(() => resolve(data.walletId)); + }; + + window.addEventListener('message', onMessage); + + const closePoll = setInterval(() => { + if (popup && popup.closed) { + startGrace(); + } + }, 400); + }); + + return { promise, notifyCodeReceived: () => startGrace() }; +} + +/** Wait for OAuth callback postMessage from /oauth/callback. */ +export function waitForOAuthCode( + expectedState: string, + popup: Window | null, + timeoutMs = 5 * 60 * 1000, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearInterval(closePoll); + window.removeEventListener('message', onMessage); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => reject(new Error('Cloud Wallet OAuth timed out'))); + }, timeoutMs); + + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data; + if (!data || data.type !== OAUTH_MESSAGE_TYPE) return; + if (typeof data.state === 'string' && data.state !== expectedState) return; + if (data.error) { + finish(() => reject(new Error(String(data.error)))); + return; + } + if (typeof data.code !== 'string' || !data.code) { + finish(() => reject(new Error('OAuth callback missing authorization code'))); + return; + } + finish(() => resolve(data.code)); + }; + + window.addEventListener('message', onMessage); + + const closePoll = setInterval(() => { + if (popup && popup.closed) { + finish(() => reject(new Error('Cloud Wallet OAuth popup was closed'))); + } + }, 400); + }); +} + +export async function beginOAuthPopupLogin(): Promise<{ + accessToken: string; + refreshToken: string; + expiresAt: number; + walletId: string; +}> { + const clientId = getCloudWalletClientId(); + if (!clientId) { + throw new Error('CLOUD_WALLET_CLIENT_ID is not configured'); + } + const state = randomUrlSafe(16); + const codeVerifier = randomUrlSafe(32); + const codeChallenge = await createPkceChallenge(codeVerifier); + const redirectUri = oauthRedirectUri(); + saveOAuthPending({ state, codeVerifier, createdAtMs: Date.now() }); + + const authorizeUrl = buildAuthorizeUrl({ + clientId, + redirectUri, + scope: CLOUD_WALLET_OAUTH_SCOPES, + state, + codeChallenge, + }); + + const popup = openOAuthPopup(authorizeUrl); + if (!popup) { + clearOAuthPending(); + throw new Error('Popup blocked — allow popups for Cloud Wallet login'); + } + + try { + const consent = waitForGamingConsentWalletId(popup); + const code = await waitForOAuthCode(state, popup); + // Code is in hand; give any in-flight consent message a short grace period + // rather than blocking on the full consent timeout. + consent.notifyCodeReceived(); + const consentWalletId = await consent.promise; + + const tokens = await exchangeAuthorizationCode({ + code, + codeVerifier, + redirectUri, + }); + + let walletId: string; + if (consentWalletId && consentWalletId !== '*') { + // Concrete walletId from the consent screen (encode passes Wallet_* through). + walletId = encodeRelayGlobalId('Wallet', consentWalletId); + } else { + // The consent screen was skipped (already consented) or granted a wildcard. + // Resolve a concrete wallet from the grant; ids are already Wallet_. + const provider: TokenProvider = { getAccessToken: async () => tokens.accessToken }; + const resolved = await fetchFirstConsentedWalletId(provider); + if (!resolved) { + throw new Error( + 'Cloud Wallet returned no consented wallets. Grant access to a specific wallet during consent.', + ); + } + walletId = resolved; + } + + try { + popup.close(); + } catch { + // ignore + } + return { ...tokens, walletId }; + } finally { + clearOAuthPending(); + } +} + +/** Handle /oauth/callback page: validate state and postMessage to opener. */ +export function handleOAuthCallbackPage(): { + status: 'ok' | 'error'; + message: string; +} { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + const error = params.get('error'); + const errorDescription = params.get('error_description'); + const pending = loadOAuthPending(); + + if (error) { + const message = errorDescription || error; + if (window.opener) { + window.opener.postMessage( + { type: OAUTH_MESSAGE_TYPE, error: message, state }, + window.location.origin, + ); + } + return { status: 'error', message }; + } + + if (!code || !state) { + const message = 'Missing authorization code or state'; + if (window.opener) { + window.opener.postMessage( + { type: OAUTH_MESSAGE_TYPE, error: message, state }, + window.location.origin, + ); + } + return { status: 'error', message }; + } + + if (!pending || pending.state !== state) { + const message = 'OAuth state mismatch — restart Cloud Wallet connect'; + if (window.opener) { + window.opener.postMessage( + { type: OAUTH_MESSAGE_TYPE, error: message, state }, + window.location.origin, + ); + } + return { status: 'error', message }; + } + + if (window.opener) { + window.opener.postMessage({ type: OAUTH_MESSAGE_TYPE, code, state }, window.location.origin); + return { + status: 'ok', + message: 'Login complete. You can close this window.', + }; + } + + return { + status: 'error', + message: 'No opener window — open Cloud Wallet connect from the game.', + }; +} + +export interface GraphQLResponse { + data?: T; + errors?: Array<{ message?: string }>; +} + +export type TokenProvider = { + getAccessToken(opts?: { forceRefresh?: boolean }): Promise; +}; + +export function createAuthTokenProvider( + getState: () => CloudWalletAuthState | null, + setState: (next: CloudWalletAuthState) => void, +): TokenProvider { + let refreshPromise: Promise | null = null; + + const doRefresh = async (state: CloudWalletAuthState): Promise => { + if (!refreshPromise) { + refreshPromise = refreshAccessToken(state.refreshToken) + .then((tokens) => { + const next: CloudWalletAuthState = { + ...state, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }; + setState(next); + return tokens.accessToken; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; + }; + + return { + async getAccessToken(opts) { + const state = getState(); + if (!state) throw new Error('Cloud Wallet is not authenticated'); + if (!opts?.forceRefresh && Date.now() < state.expiresAt - 60_000) { + return state.accessToken; + } + return doRefresh(state); + }, + }; +} + +export async function graphqlRequest( + query: string, + variables: Record | undefined, + tokenProvider: TokenProvider, + apiBase = getCloudWalletApiUrl(), +): Promise { + const run = async (accessToken: string) => { + const res = await fetch(`${apiBase.replace(/\/$/, '')}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }); + return res; + }; + + let accessToken = await tokenProvider.getAccessToken(); + let res = await run(accessToken); + + if (res.status === 401) { + accessToken = await tokenProvider.getAccessToken({ forceRefresh: true }); + res = await run(accessToken); + } + + const text = await res.text(); + let payload: GraphQLResponse; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Cloud Wallet GraphQL returned non-JSON (${res.status})`); + } + + if (!res.ok || payload.errors?.length) { + const msg = + payload.errors + ?.map((e) => e.message) + .filter(Boolean) + .join('; ') || `GraphQL request failed (${res.status})`; + throw new Error(msg); + } + if (payload.data === undefined) { + throw new Error('Cloud Wallet GraphQL response missing data'); + } + return payload.data; +} + +/** + * Query the wallets this OAuth grant has consented to and return the first id. + * + * Cloud Wallet returns ids already in `Wallet_` form, so callers must use + * the value as-is (no `encodeRelayGlobalId`). Returns undefined when the grant + * has no consented wallets. + */ +export async function fetchFirstConsentedWalletId( + tokenProvider: TokenProvider, +): Promise { + const data = await graphqlRequest<{ oauthConsentedWallets: Array<{ id: string }> | null }>( + `query OAuthConsentedWallets { oauthConsentedWallets { id } }`, + undefined, + tokenProvider, + ); + return data.oauthConsentedWallets?.[0]?.id; +} + +export function encodeRelayGlobalId(typename: string, id: string): string { + // Cloud Wallet already emits global ids as `Typename_xxx` (e.g. Wallet_abc). + // Pass those through untouched rather than re-encoding them as base64. + if (id.startsWith(`${typename}_`)) return id; + // Already a Relay id? + if (id.includes(':') === false && /^[A-Za-z0-9+/=]+$/.test(id)) { + try { + const decoded = atob(id); + if (decoded.startsWith(`${typename}:`)) return id; + } catch { + // fall through + } + } + if (id.startsWith(`${typename}:`)) { + return btoa(id); + } + return btoa(`${typename}:${id}`); +} + +export function normalizeHex(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') { + return value.trim().toLowerCase().replace(/^0x/, ''); + } + if ( + typeof value === 'object' && + value && + (value as any).type === 'Buffer' && + Array.isArray((value as any).data) + ) { + return Array.from((value as any).data as number[], (b) => + (b & 0xff).toString(16).padStart(2, '0'), + ).join(''); + } + return String(value).toLowerCase().replace(/^0x/, ''); +} + +export function with0x(hex: string): string { + const n = normalizeHex(hex); + return n ? `0x${n}` : '0x'; +} + +export { + getCloudWalletApiUrl, + getCloudWalletClientId, + getCloudWalletUiUrl, +} from './cloudWalletConfig'; diff --git a/front-end/src/lib/session/persistence.ts b/front-end/src/lib/session/persistence.ts index dfdc81559..70cc4d8d1 100644 --- a/front-end/src/lib/session/persistence.ts +++ b/front-end/src/lib/session/persistence.ts @@ -100,9 +100,9 @@ function parsePreferences(value: unknown): SessionPreferencesSave { const blockchainType = fields.blockchainType === undefined ? undefined - : parseDiscriminant<'simulator' | 'walletconnect'>( + : parseDiscriminant<'simulator' | 'walletconnect' | 'cloud'>( fields.blockchainType, - new Set(['simulator', 'walletconnect']), + new Set(['simulator', 'walletconnect', 'cloud']), 'preferences.blockchainType', ); const network = diff --git a/front-end/src/lib/session/persistencePayloads.ts b/front-end/src/lib/session/persistencePayloads.ts index cab4ab808..31b4be3bd 100644 --- a/front-end/src/lib/session/persistencePayloads.ts +++ b/front-end/src/lib/session/persistencePayloads.ts @@ -237,7 +237,8 @@ export function validateCommonFields(save: SessionSave): void { if ( save.preferences.blockchainType !== undefined && save.preferences.blockchainType !== 'simulator' && - save.preferences.blockchainType !== 'walletconnect' + save.preferences.blockchainType !== 'walletconnect' && + save.preferences.blockchainType !== 'cloud' ) { throw new Error('Garbled save: invalid blockchainType'); } diff --git a/front-end/src/lib/session/saveEnvelope.ts b/front-end/src/lib/session/saveEnvelope.ts index ccec2018a..84d2c8c94 100644 --- a/front-end/src/lib/session/saveEnvelope.ts +++ b/front-end/src/lib/session/saveEnvelope.ts @@ -12,7 +12,7 @@ import type { export const SESSION_SAVE_SCHEMA = 'chia-gaming-session' as const; export const SESSION_SAVE_VERSION = 13n; -export type BlockchainType = 'simulator' | 'walletconnect'; +export type BlockchainType = 'simulator' | 'walletconnect' | 'cloud'; export type ChiaNetwork = 'mainnet' | 'testnet'; diff --git a/front-end/src/lib/tests/cloud_wallet_oauth.test.ts b/front-end/src/lib/tests/cloud_wallet_oauth.test.ts new file mode 100644 index 000000000..ab12a8775 --- /dev/null +++ b/front-end/src/lib/tests/cloud_wallet_oauth.test.ts @@ -0,0 +1,452 @@ +function makeStorage() { + const map = new Map(); + return { + get length() { + return map.size; + }, + key(i: number) { + return [...map.keys()][i] ?? null; + }, + getItem(k: string) { + return map.has(k) ? map.get(k)! : null; + }, + setItem(k: string, v: string) { + map.set(k, String(v)); + }, + removeItem(k: string) { + map.delete(k); + }, + clear() { + map.clear(); + }, + }; +} + +function setTestGlobal(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); +} + +/** Minimal window stand-in that lets tests dispatch `message` events manually. */ +function makeFakeWindow() { + const handlers: Record void>> = {}; + return { + addEventListener(type: string, cb: (e: unknown) => void) { + (handlers[type] ||= []).push(cb); + }, + removeEventListener(type: string, cb: (e: unknown) => void) { + handlers[type] = (handlers[type] || []).filter((f) => f !== cb); + }, + emit(type: string, event: unknown) { + (handlers[type] || []).slice().forEach((cb) => cb(event)); + }, + }; +} + +setTestGlobal('localStorage', makeStorage()); +setTestGlobal('sessionStorage', makeStorage()); +setTestGlobal('window', globalThis); + +import { + buildAuthorizeUrl, + createPkceChallenge, + encodeRelayGlobalId, + fetchFirstConsentedWalletId, + handleOAuthCallbackPage, + normalizeHex, + oauthRedirectUri, + signatureRequestApproveUrl, + waitForGamingConsentWalletId, + with0x, + GAMING_CONSENT_MESSAGE_TYPE, + OAUTH_MESSAGE_TYPE, +} from '../../hooks/cloudWalletOAuth'; +import { + saveOAuthPending, + clearOAuthPending, + clearCloudWalletAuth, +} from '../../hooks/cloudWalletAuth'; +import { + clearCloudWalletConfig, + getCloudWalletApiUrl, + getCloudWalletClientId, + getCloudWalletUiUrl, + loadCloudWalletConfig, + saveCloudWalletConfig, +} from '../../hooks/cloudWalletConfig'; +import { CloudBlockchainInterface } from '../../hooks/CloudBlockchainInterface'; +import { + conditionsForGraphql, + jsonSafeVariables, + selectCoinStringForAmount, +} from '../../hooks/cloudWalletHelpers'; +import { encodeU64AsClvmHex } from '../../util'; + +describe('cloudWalletOAuth helpers', () => { + beforeEach(() => { + setTestGlobal('localStorage', makeStorage()); + setTestGlobal('sessionStorage', makeStorage()); + clearOAuthPending(); + clearCloudWalletAuth(); + }); + + it('createPkceChallenge is stable S256 base64url', async () => { + const challenge = await createPkceChallenge('test-verifier-value'); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).not.toContain('+'); + expect(challenge).not.toContain('/'); + expect(await createPkceChallenge('test-verifier-value')).toBe(challenge); + }); + + it('buildAuthorizeUrl includes PKCE and scopes', () => { + const url = buildAuthorizeUrl({ + clientId: 'client-1', + redirectUri: 'http://127.0.0.1:8080/oauth/callback', + scope: 'wallet.read offline_access', + state: 'state123', + codeChallenge: 'challengeABC', + apiBase: 'http://api.example', + }); + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe('http://api.example/authorize'); + expect(parsed.searchParams.get('client_id')).toBe('client-1'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('code_challenge')).toBe('challengeABC'); + expect(parsed.searchParams.get('scope')).toBe('wallet.read offline_access'); + expect(parsed.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:8080/oauth/callback'); + expect(parsed.searchParams.get('chia_gaming_client')).toBe('true'); + }); + + it('oauthRedirectUri appends callback path', () => { + expect(oauthRedirectUri('https://game.example/')).toBe('https://game.example/oauth/callback'); + }); + + it('signatureRequestApproveUrl builds UI path', () => { + expect(signatureRequestApproveUrl('SignatureRequest_abc', 'https://ui.example')).toBe( + 'https://ui.example/signature-requests/SignatureRequest_abc', + ); + }); + + it('normalizeHex and with0x strip/add prefixes', () => { + expect(normalizeHex('0xAaBb')).toBe('aabb'); + expect(with0x('Aa')).toBe('0xaa'); + }); + + it('encodeRelayGlobalId prefixes Wallet typename', () => { + const id = encodeRelayGlobalId('Wallet', 'wal_1'); + expect(atob(id)).toBe('Wallet:wal_1'); + expect(encodeRelayGlobalId('Wallet', id)).toBe(id); + }); + + it('encodeRelayGlobalId passes through Cloud Wallet global ids unchanged', () => { + // Cloud Wallet emits `Typename_xxx` ids; these must not be re-encoded. + expect(encodeRelayGlobalId('Wallet', 'Wallet_abc123')).toBe('Wallet_abc123'); + // A non-matching prefix still gets the base64 `Typename:id` treatment. + expect(atob(encodeRelayGlobalId('Wallet', 'Other_abc'))).toBe('Wallet:Other_abc'); + }); + + it('handleOAuthCallbackPage posts code to opener on success', () => { + saveOAuthPending({ + state: 'st1', + codeVerifier: 'v', + createdAtMs: Date.now(), + }); + const posted: any[] = []; + const opener = { + postMessage: (msg: unknown, origin: string) => posted.push({ msg, origin }), + }; + (globalThis as any).window = globalThis; + (globalThis as any).opener = opener; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { + origin: 'http://127.0.0.1', + search: '?code=authcode&state=st1', + href: 'http://127.0.0.1/oauth/callback?code=authcode&state=st1', + }, + }); + + const result = handleOAuthCallbackPage(); + expect(result.status).toBe('ok'); + expect(posted[0]?.msg).toEqual({ + type: OAUTH_MESSAGE_TYPE, + code: 'authcode', + state: 'st1', + }); + }); + + it('handleOAuthCallbackPage rejects state mismatch', () => { + saveOAuthPending({ + state: 'expected', + codeVerifier: 'v', + createdAtMs: Date.now(), + }); + const posted: any[] = []; + (globalThis as any).window = globalThis; + (globalThis as any).opener = { + postMessage: (msg: unknown) => posted.push(msg), + }; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { + origin: 'http://127.0.0.1', + search: '?code=x&state=wrong', + href: 'http://127.0.0.1/oauth/callback?code=x&state=wrong', + }, + }); + + const result = handleOAuthCallbackPage(); + expect(result.status).toBe('error'); + expect(String((posted[0] as any)?.error)).toMatch(/state mismatch/i); + }); +}); + +describe('cloudWalletConfig', () => { + beforeEach(() => { + setTestGlobal('localStorage', makeStorage()); + clearCloudWalletConfig(); + }); + + it('falls back to env defaults when nothing is stored', () => { + expect(getCloudWalletApiUrl()).toBe('http://127.0.0.1:3001'); + expect(getCloudWalletUiUrl()).toBe('http://127.0.0.1:3000'); + expect(getCloudWalletClientId()).toBe(''); + }); + + it('persisted values take precedence and are normalized', () => { + saveCloudWalletConfig({ + clientId: ' client-1 ', + apiUrl: 'http://api.local/', + uiUrl: 'http://ui.local/', + }); + expect(getCloudWalletClientId()).toBe('client-1'); + expect(getCloudWalletApiUrl()).toBe('http://api.local'); + expect(getCloudWalletUiUrl()).toBe('http://ui.local'); + expect(loadCloudWalletConfig()).toEqual({ + clientId: 'client-1', + apiUrl: 'http://api.local', + uiUrl: 'http://ui.local', + }); + }); +}); + +describe('CloudBlockchainInterface beginConnect', () => { + beforeEach(() => { + setTestGlobal('localStorage', makeStorage()); + setTestGlobal('sessionStorage', makeStorage()); + clearCloudWalletConfig(); + clearCloudWalletAuth(); + }); + + it('fresh connect exposes string setup fields for OAuth', async () => { + const iface = new CloudBlockchainInterface(); + const setup = await iface.beginConnect('uid', true); + expect(setup.skipQr).toBe(true); + expect(setup.title).toBe('Cloud Wallet'); + expect(setup.fields?.clientId?.type).toBe('string'); + expect(setup.fields?.apiUrl?.type).toBe('string'); + expect(setup.fields?.uiUrl?.type).toBe('string'); + }); + + it('finalize persists config before attempting OAuth', async () => { + const iface = new CloudBlockchainInterface(); + const setup = await iface.beginConnect('uid', true); + // OAuth cannot complete in the test environment (no popup), so finalize + // rejects -- but only after the config has been saved. + await expect( + setup.finalize({ + clientId: 'client-xyz', + apiUrl: 'http://api.local/', + uiUrl: 'http://ui.local/', + }), + ).rejects.toBeTruthy(); + expect(loadCloudWalletConfig()).toEqual({ + clientId: 'client-xyz', + apiUrl: 'http://api.local', + uiUrl: 'http://ui.local', + }); + }); + + it('finalize rejects when no client id is available', async () => { + const iface = new CloudBlockchainInterface(); + const setup = await iface.beginConnect('uid', true); + await expect(setup.finalize({ clientId: '', apiUrl: '', uiUrl: '' })).rejects.toThrow( + /client id/i, + ); + }); +}); + +describe('waitForGamingConsentWalletId grace period', () => { + let fakeWindow: ReturnType; + + beforeEach(() => { + setTestGlobal('localStorage', makeStorage()); + clearCloudWalletConfig(); + jest.useFakeTimers(); + fakeWindow = makeFakeWindow(); + setTestGlobal('window', fakeWindow); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + setTestGlobal('window', globalThis); + }); + + const consentEvent = (walletId: string, origin = 'http://127.0.0.1:3000') => ({ + origin, + data: { type: GAMING_CONSENT_MESSAGE_TYPE, walletId }, + }); + + it('resolves immediately when the consent message arrives', async () => { + const popup = { closed: false } as unknown as Window; + const { promise } = waitForGamingConsentWalletId(popup, 60_000, 500); + fakeWindow.emit('message', consentEvent('Wallet_immediate')); + await expect(promise).resolves.toBe('Wallet_immediate'); + }); + + it('resolves a late walletId posted during the popup-close grace period', async () => { + const popup = { closed: false } as unknown as Window; + const { promise } = waitForGamingConsentWalletId(popup, 60_000, 500); + popup.closed = true; + jest.advanceTimersByTime(400); // close poll detects close -> grace begins + fakeWindow.emit('message', consentEvent('Wallet_late')); + await expect(promise).resolves.toBe('Wallet_late'); + }); + + it('resolves undefined only after the grace period elapses with no message', async () => { + const popup = { closed: false } as unknown as Window; + const { promise } = waitForGamingConsentWalletId(popup, 60_000, 500); + popup.closed = true; + jest.advanceTimersByTime(400); // detect close, start 500ms grace + let settled = false; + void promise.then(() => { + settled = true; + }); + jest.advanceTimersByTime(499); + await Promise.resolve(); + expect(settled).toBe(false); + jest.advanceTimersByTime(1); + await expect(promise).resolves.toBeUndefined(); + }); + + it('notifyCodeReceived starts the grace period while the popup stays open', async () => { + const popup = { closed: false } as unknown as Window; + const { promise, notifyCodeReceived } = waitForGamingConsentWalletId(popup, 60_000, 500); + notifyCodeReceived(); + // A late message during the grace window still wins over the timeout. + fakeWindow.emit('message', consentEvent('Wallet_after_code')); + await expect(promise).resolves.toBe('Wallet_after_code'); + }); + + it('ignores consent messages from an unexpected origin', async () => { + const popup = { closed: false } as unknown as Window; + const { promise, notifyCodeReceived } = waitForGamingConsentWalletId(popup, 60_000, 500); + fakeWindow.emit('message', consentEvent('Wallet_evil', 'http://evil.example')); + notifyCodeReceived(); + jest.advanceTimersByTime(500); + await expect(promise).resolves.toBeUndefined(); + }); +}); + +describe('fetchFirstConsentedWalletId', () => { + const provider = { getAccessToken: async () => 'access-token' }; + + beforeEach(() => { + setTestGlobal('localStorage', makeStorage()); + clearCloudWalletConfig(); + }); + + afterEach(() => { + setTestGlobal('fetch', undefined); + }); + + function mockGraphql(data: unknown) { + const fetchMock = jest.fn(async () => ({ + status: 200, + ok: true, + text: async () => JSON.stringify({ data }), + })); + setTestGlobal('fetch', fetchMock); + return fetchMock; + } + + it('returns the first consented wallet id unchanged (no base64 re-encode)', async () => { + mockGraphql({ + oauthConsentedWallets: [{ id: 'Wallet_abc' }, { id: 'Wallet_def' }], + }); + await expect(fetchFirstConsentedWalletId(provider)).resolves.toBe('Wallet_abc'); + }); + + it('returns undefined when there are no consented wallets', async () => { + mockGraphql({ oauthConsentedWallets: [] }); + await expect(fetchFirstConsentedWalletId(provider)).resolves.toBeUndefined(); + }); + + it('returns undefined when oauthConsentedWallets is null', async () => { + mockGraphql({ oauthConsentedWallets: null }); + await expect(fetchFirstConsentedWalletId(provider)).resolves.toBeUndefined(); + }); +}); + +describe('CloudBlockchainInterface helpers', () => { + it('conditionsForGraphql maps opcodes and maxHeight', () => { + const conditions = conditionsForGraphql([{ opcode: 51n, args: ['ph', '64'] }], 100n); + expect(conditions[0]).toEqual({ opcode: '51', args: ['ph', '64'] }); + expect(conditions[1]).toEqual({ + opcode: '87', + args: [encodeU64AsClvmHex(100n)], + }); + }); + + it('jsonSafeVariables converts bigint recursively', () => { + expect(jsonSafeVariables({ amount: 10n, nested: { fee: 0n }, list: [1n] })).toEqual({ + amount: '10', + nested: { fee: '0' }, + list: ['1'], + }); + }); + + it('selectCoinStringForAmount picks smallest sufficient coin', () => { + const coin = selectCoinStringForAmount( + [ + { + parentCoinInfo: '11'.repeat(32), + puzzleHash: '22'.repeat(32), + amount: 50n, + }, + { + parentCoinInfo: '33'.repeat(32), + puzzleHash: '44'.repeat(32), + amount: 200n, + }, + { + parentCoinInfo: '55'.repeat(32), + puzzleHash: '66'.repeat(32), + amount: 100n, + }, + ], + 80n, + ); + expect(coin).not.toBeNull(); + expect(coin!.startsWith('55'.repeat(32) + '66'.repeat(32))).toBe(true); + }); + + it('selectCoinStringForAmount returns null when none suffice', () => { + expect( + selectCoinStringForAmount( + [ + { + parentCoinInfo: '11'.repeat(32), + puzzleHash: '22'.repeat(32), + amount: 10n, + }, + ], + 100n, + ), + ).toBeNull(); + }); +}); diff --git a/front-end/src/lib/tests/save.harness.ts b/front-end/src/lib/tests/save.harness.ts index e8e90d066..2dc98e6b4 100644 --- a/front-end/src/lib/tests/save.harness.ts +++ b/front-end/src/lib/tests/save.harness.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto'; import { saveSession, type SessionSave, _resetForTests } from '../../hooks/save'; import { SESSION_DB_NAME } from '../session/indexedDb'; +import type { BlockchainType } from '../session/saveEnvelope'; import { liveSave } from './session_save_envelope.fixtures'; export const testIndexedDb = indexedDB; @@ -71,7 +72,7 @@ export function saveLiveFields(fields: Record = sampleSession): void saveSession({ scope: 'common', preferences: { - blockchainType: fields.blockchainType as 'simulator' | 'walletconnect' | undefined, + blockchainType: fields.blockchainType as BlockchainType | undefined, defaultFee: fields.defaultFee as bigint | undefined, hubUrl: fields.hubUrl as string | undefined, }, @@ -87,7 +88,7 @@ export function saveLiveFields(fields: Record = sampleSession): } export function savePreferences(fields: { - blockchainType?: 'simulator' | 'walletconnect'; + blockchainType?: BlockchainType; hubUrl?: string; }): Promise { return saveSession({ scope: 'common', preferences: fields }); diff --git a/front-end/src/lib/tests/save.state.test.ts b/front-end/src/lib/tests/save.state.test.ts index 70e2cdc47..626ceb7f8 100644 --- a/front-end/src/lib/tests/save.state.test.ts +++ b/front-end/src/lib/tests/save.state.test.ts @@ -358,6 +358,16 @@ describe('flat state', () => { expect(getBlockchainType()).toBe('walletconnect'); }); + it('getBlockchainType accepts cloud', async () => { + _resetForTests(); + setTestGlobal('localStorage', makeStorage()); + expect(getBlockchainType()).toBeUndefined(); + await savePreferences({ blockchainType: 'cloud' }); + expect(getBlockchainType()).toBe('cloud'); + await flushSessionSave(); + expect(decodeSessionSaveEnvelope(loadState()).save.preferences.blockchainType).toBe('cloud'); + }); + it('saveSession replaces the live phase payload', () => { saveLiveFields(); const state = loadState(); diff --git a/front-end/src/lib/tests/session_save_envelope.validation.test.ts b/front-end/src/lib/tests/session_save_envelope.validation.test.ts index 7527e6e82..78bdf6c6c 100644 --- a/front-end/src/lib/tests/session_save_envelope.validation.test.ts +++ b/front-end/src/lib/tests/session_save_envelope.validation.test.ts @@ -144,6 +144,18 @@ describe('validateSessionSaveEnvelope', () => { expect(decodeSessionSaveEnvelope(terminal).phase).toBe('terminal'); }); + it('accepts cloud as preferences.blockchainType', () => { + const decoded = decodeSessionSaveEnvelope(baseSave({ blockchainType: 'cloud' })); + expect(decoded.phase).toBe('preferences'); + expect(decoded.save.preferences.blockchainType).toBe('cloud'); + }); + + it('rejects an unknown preferences.blockchainType', () => { + expect(() => decodeSessionSaveEnvelope(baseSave({ blockchainType: 'not-a-wallet' }))).toThrow( + 'Garbled save: invalid preferences.blockchainType: not-a-wallet', + ); + }); + it.each([ ['schema', { gameSessionSchemaVersion: undefined }], ['message counter', { messageNumber: undefined }], diff --git a/front-end/src/types/ChiaGaming.ts b/front-end/src/types/ChiaGaming.ts index fa4191011..7e9b23b54 100644 --- a/front-end/src/types/ChiaGaming.ts +++ b/front-end/src/types/ChiaGaming.ts @@ -601,16 +601,19 @@ export interface BlockchainInboundAddressResult { puzzleHash: string; } -export interface ConnectionField { - label: string; - default: bigint; -} +export type ConnectionField = + | { type: 'bigint'; label: string; default: bigint } + | { type: 'string'; label: string; default: string }; + +export type ConnectionFieldValues = Record; export interface ConnectionSetup { qrUri: string; skipQr?: boolean; - fields?: { balance?: ConnectionField }; - finalize(values?: { balance?: bigint }): Promise; + title?: string; + description?: string; + fields?: Record; + finalize(values?: ConnectionFieldValues): Promise; } export interface InternalBlockchainInterface { @@ -645,8 +648,9 @@ export interface InternalBlockchainInterface { isConnected(): boolean; onConnectionChange(cb: (connected: boolean) => void): () => void; // True when this backend can fund/resolve channels (hub may advertise - // not-busy). Simulator: ready whenever connected. WalletConnect: ready once a - // full-node peer is verified. Peer count is a private implementation detail. + // not-busy). Simulator and Cloud Wallet: ready whenever connected. + // WalletConnect: ready once a full-node peer is verified. Peer count is a + // private implementation detail. isReadyForPlay(): boolean; onPlayReadinessChange(cb: (ready: boolean) => void): () => void; }