import { useEffect, useRef, useState, } from "react" import type { ReactElement } from "react" import { sendCamContractCall, simulateCamContractCall, } from "@cam/evm-viem" import type { ResolvedButtonNode, } from "@cam/screen" import { createCamViewerSession, } from "@cam/viewer" import type { CamViewerLoadedSnapshot, CamViewerPreparedContractCall, CamViewerSession, } from "@cam/viewer" import { createPublicClient, http, } from "viem" import { connectInjectedWallet, createInjectedWalletClient, ensureConnectedWalletAccount, ensureInjectedWalletChain, initialWalletState, walletChain, walletLabel, } from "./wallet.ts" import type { WalletState } from "./wallet.ts" import { createInjectedWalletBinding } from "./wallet-binding.ts" import { errorMessage } from "./errors.ts" import { calculateDocumentHashFromFile, calculateDocumentHashFromURL } from "./document-hash.ts" import { assertHostHasCode, assertRpcChain, createPinnedOriginResourceLoader, displayRpcEndpoint, parseStartupOptions, parseViewFragment, urlForView, readStartupPolicy, } from "./startup.ts" import type { StartupOptions } from "./startup.ts" import { ConnectionSummary, PreparedCallView, UiView, } from "./components.tsx" import { type PreparedCallSubmitterPorts, SubmissionOutcomeUnknownError, submittedTransactionDiagnosis, submitPreparedContractCall, waitForSubmittedTransactionReceipt, } from "./transactions.ts" type LoadState = | { readonly status: "loading" } | { readonly status: "ready"; readonly runtime: AppRuntime; readonly snapshot: CamViewerLoadedSnapshot } | { readonly status: "failed"; readonly message: string } type AppPublicClient = ReturnType type AppRuntime = { readonly startup: StartupOptions readonly publicClient: AppPublicClient readonly session: CamViewerSession } type Notice = { readonly message: string; readonly kind: "error" | "status" } type FeedbackArrival = { readonly target: "notice" | "prepared" | "view" | "transaction" readonly interaction: number } const RECEIPT_WAIT_TIMEOUT_MS = 20_000 const RECEIPT_POLLING_INTERVAL_MS = 500 const PREPARED_CALL_SUBMITTER_PORTS: PreparedCallSubmitterPorts< ReturnType, ReturnType > = { ensureAccount: ensureConnectedWalletAccount, simulate: simulateCamContractCall, ensureChain: ensureInjectedWalletChain, createWalletClient: createInjectedWalletClient, chain: walletChain, send: async ({ walletClient, chain, call }) => await sendCamContractCall({ walletClient, chain, call }), } export function App(): ReactElement { const [loadState, setLoadState] = useState({ status: "loading" }) const [wallet, setWallet] = useState(() => initialWalletState()) const [walletChainMatches, setWalletChainMatches] = useState(true) const [walletRefreshing, setWalletRefreshing] = useState(false) const [walletAccountSynchronized, setWalletAccountSynchronized] = useState(true) const [notice, setNotice] = useState(undefined) const [transactionNotice, setTransactionNotice] = useState(undefined) const [preparedCall, setPreparedCallState] = useState(undefined) const preparedCallRef = useRef(undefined) const [sending, setSending] = useState(false) const [changingView, setChangingView] = useState(false) const interactionRevision = useRef(0) const sendRevision = useRef(0) const sendingRef = useRef(false) const changingViewRef = useRef(false) const walletRefreshingRef = useRef(false) const walletAccountSynchronizedRef = useRef(true) const walletBindingRef = useRef<{ connect(): Promise; resume(): Promise; dispose(): void } | undefined>(undefined) const committedUrlRef = useRef(window.location.href) const noticeRef = useRef(null) const transactionNoticeRef = useRef(null) const preparedRef = useRef(null) const viewRef = useRef(null) const [feedbackArrival, setFeedbackArrival] = useState(undefined) useEffect(() => { if (feedbackArrival === undefined || feedbackArrival.interaction !== interactionRevision.current) return const target = feedbackArrival.target === "notice" ? noticeRef.current : feedbackArrival.target === "prepared" ? preparedRef.current : feedbackArrival.target === "transaction" ? transactionNoticeRef.current : viewRef.current target?.focus() }, [feedbackArrival]) useEffect(() => { let cancelled = false let loadedSession: CamViewerSession | undefined async function load(): Promise { try { const startup = parseStartupOptions(new URL(window.location.href), readStartupPolicy(import.meta.env)) const publicClient = createPublicClient({ transport: http(startup.rpcUrl), }) await assertRpcChain(publicClient, startup.chainId) await assertHostHasCode(publicClient, startup.host) const session = createCamViewerSession({ publicClient, host: { chainId: startup.chainId, address: startup.host, }, account: { address: startup.account, }, inputs: startup.inputs, ...(startup.route === undefined ? {} : { route: startup.route }), allowUnsignedCamHash: startup.allowUnsignedCamHash, loadResource: createPinnedOriginResourceLoader(startup.resourceOrigin), }) const snapshot = await session.load() loadedSession = session if (!cancelled) { const ready = { status: "ready" as const, runtime: { startup, publicClient, session, }, snapshot, } commitSnapshot(ready, snapshot) } } catch (error) { if (!cancelled) { setLoadState({ status: "failed", message: errorMessage(error), }) } } } void load() return () => { cancelled = true if (loadedSession !== undefined) { const documentHashes = loadedSession.snapshot().documentHashes if (documentHashes !== undefined) { for (const name of Object.keys(documentHashes)) loadedSession.cancelDocumentHash(name) } } } }, []) const loadedRuntime = loadState.status === "ready" ? loadState.runtime : undefined useEffect(() => { if (loadedRuntime === undefined) return const ready = requireReadyState(loadState) const binding = createInjectedWalletBinding({ chainId: loadedRuntime.startup.chainId, connect: () => connectInjectedWallet(loadedRuntime.startup), isViewBusy: () => changingViewRef.current, setAccount: async address => { setViewChanging(true) try { return await loadedRuntime.session.setAccount({ address }) } finally { setViewChanging(false) } }, commitAccount: snapshot => commitSnapshot(ready, snapshot), invalidate: invalidatePreparedInteraction, onState: state => { walletRefreshingRef.current = state.refreshing walletAccountSynchronizedRef.current = state.accountSynchronized setWallet(state.wallet) setWalletRefreshing(state.refreshing) setWalletAccountSynchronized(state.accountSynchronized) setWalletChainMatches(state.chainMatches) if (!state.chainMatches) { showError(`Wallet network differs from viewer chain ${loadedRuntime.startup.chainId}. The viewer stays on its configured network. Connect the wallet to use that network.`, false) } else if (!state.refreshing && state.accountSynchronized) { showStatus(state.wallet.status === "connected" ? "Wallet connected. Viewed account updated." : "Wallet disconnected. The last viewed account is retained.", false) } }, onError: error => showError(`Cannot synchronize the wallet account: ${errorMessage(error)} The last viewed account is retained. Connect the wallet to retry.`, false), }) walletBindingRef.current = binding return () => { binding.dispose() if (walletBindingRef.current === binding) walletBindingRef.current = undefined } }, [loadedRuntime]) useEffect(() => { async function navigateFromFragment(): Promise { if (loadState.status !== "ready") { // Startup owns the URL until the first view loads. A changed startup // request is handled by the same tab, rather than overwriting it later. window.location.reload() return } if (sendingRef.current || viewInteractionBlocked()) { window.history.replaceState(window.history.state, "", committedUrlRef.current) showStatus("Finish the current operation before opening another view.", false) return } const ready = loadState const revision = nextInteractionRevision() setPreparedCall(undefined) setNotice(undefined) setViewChanging(true) try { const requested = parseViewFragment(window.location.hash) const snapshot = await ready.runtime.session.navigate(requested.route, requested.inputs) commitSnapshot(ready, snapshot) if (!isCurrentInteraction(revision)) return arriveAtFeedback("view") } catch (error) { window.history.replaceState(window.history.state, "", committedUrlRef.current) if (!isCurrentInteraction(revision)) return showError(`Cannot open the requested view: ${errorMessage(error)}`, true) } finally { setViewChanging(false) } } const onHashChange = () => { void navigateFromFragment() } window.addEventListener("hashchange", onHashChange) return () => window.removeEventListener("hashchange", onHashChange) }, [loadState]) async function dispatch(action: ResolvedButtonNode): Promise { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) const revision = nextInteractionRevision() setNotice(undefined) setPreparedCall(undefined) try { setViewChanging(true) let result try { result = await ready.runtime.session.dispatchAction(action) if (result.type === "navigated") commitSnapshot(ready, result.snapshot) } finally { setViewChanging(false) } if (!isCurrentInteraction(revision)) return if (result.type === "navigated") { arriveAtFeedback("view") return } await preflightPreparedCall(result.call) if (!isCurrentInteraction(revision)) return setPreparedCall(result.call) showStatus(!walletChainMatches ? `Simulation passed on viewer chain ${ready.runtime.startup.chainId}, but the wallet is on another network. Connect the wallet to the configured network, then prepare the call again.` : wallet.status === "connected" ? "Prepared contract call. Simulation passed; review it before sending." : "Prepared contract call. Simulation passed; connect a wallet to send it.", false) arriveAtFeedback("prepared") } catch (error) { if (!isCurrentInteraction(revision)) return showError(errorMessage(error), true) } } async function preflightPreparedCall(call: CamViewerPreparedContractCall): Promise { const ready = requireReadyState(loadState) await simulateCamContractCall({ publicClient: ready.runtime.publicClient, account: wallet.status === "connected" ? wallet.address : currentViewerAccount(ready), call, }) } async function connectWallet(): Promise { if (sendingRef.current || changingViewRef.current || walletRefreshingRef.current) return await walletBindingRef.current?.connect() } async function goBack(): Promise { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) const revision = nextInteractionRevision() setPreparedCall(undefined) setNotice(undefined) setViewChanging(true) try { const snapshot = await ready.runtime.session.back() commitSnapshot(ready, snapshot) if (!isCurrentInteraction(revision)) return arriveAtFeedback("view") } catch (error) { if (isCurrentInteraction(revision)) showError(errorMessage(error), true) } finally { setViewChanging(false) } } function discardPreparedCall(): void { if (sendingRef.current || viewInteractionBlocked()) return invalidatePreparedInteraction() setNotice(undefined) arriveAtFeedback("view") } async function sendPreparedCall(call: CamViewerPreparedContractCall): Promise { if (preparedCallRef.current !== call) return if (viewInteractionBlocked() || !walletChainMatches) return if (sendingRef.current) { showStatus("A wallet submission is already in progress.", false) return } if (wallet.status !== "connected") { showError("Connect a wallet before sending.", true) return } const interaction = interactionRevision.current const send = nextSendRevision() // Before wallet submission, stale interactions may abort silently. After a // tx hash exists, the send revision owns user-visible transaction feedback. const ownsInteraction = () => isCurrentInteraction(interaction) const ownsSend = () => isCurrentSend(send) let submittedTxHash: `0x${string}` | undefined // React disables the button after state commit, but a second click can // enter this handler before that render. The ref is the synchronous // transaction-submission gate. sendingRef.current = true cancelPendingDocumentHashes() setSending(true) setNotice(undefined) try { const ready = requireReadyState(loadState) const txHash = await submitPreparedContractCall({ ports: PREPARED_CALL_SUBMITTER_PORTS, publicClient: ready.runtime.publicClient, walletAddress: wallet.address, startup: ready.runtime.startup, call, shouldContinue: () => ownsInteraction() && ownsSend(), }) if (txHash === undefined) return submittedTxHash = txHash if (!ownsSend()) return reportTransaction(`Transaction sent: ${txHash}`, "status", false) const nonceGap = await submittedTransactionDiagnosis({ client: ready.runtime.publicClient, txHash, rpcEndpoint: displayRpcEndpoint(ready.runtime.startup.rpcUrl), includePendingAdvice: false, }) if (!ownsSend()) return if (nonceGap !== undefined) { throw new Error(nonceGap) } const receipt = await waitForReceipt(ready, txHash) if (!ownsSend()) return if (receipt.status !== "success") { throw new Error(`Transaction reverted: ${txHash}`) } reportTransaction(`Transaction confirmed in block ${receipt.blockNumber.toString()}. Transaction hash: ${txHash}.`, "status", ownsInteraction()) if (ownsInteraction()) { setPreparedCall(undefined) } if (call.then.namespace === "routes" && ownsInteraction()) { try { setViewChanging(true) try { const snapshot = await ready.runtime.session.navigate(call.then.function, call.then.args) if (!ownsSend()) return commitSnapshot(ready, snapshot) } finally { setViewChanging(false) } } catch (error) { if (!ownsInteraction() || !ownsSend()) return showError(`The transaction is confirmed, but the view could not be refreshed: ${errorMessage(error)}`, true) } } } catch (error) { if (!ownsSend() || (submittedTxHash === undefined && !(error instanceof SubmissionOutcomeUnknownError) && !ownsInteraction())) return if (submittedTxHash !== undefined) { const message = errorMessage(error) if (ownsInteraction()) { setPreparedCall(undefined) } reportTransaction(message.includes(submittedTxHash) ? message : `${message} Transaction hash: ${submittedTxHash}.`, "error", ownsInteraction()) } else if (error instanceof SubmissionOutcomeUnknownError) { if (ownsInteraction()) setPreparedCall(undefined) reportTransaction(errorMessage(error), "error", ownsInteraction()) } else { showError(errorMessage(error), true) } } finally { sendingRef.current = false if (ownsSend()) { setSending(false) } } } function updateInput(name: string, value: string): void { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) try { invalidatePreparedInteraction() setLoadState({ ...ready, snapshot: ready.runtime.session.updateState({ [name]: value }), }) } catch (error) { showError(errorMessage(error), true) } } function updateAmount(name: string, representation: "base" | "display", text: string, options?: { readonly composing?: boolean }): void { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) try { invalidatePreparedInteraction() setLoadState({ ...ready, snapshot: ready.runtime.session.editAmount(name, representation, text, options) }) } catch (error) { showError(errorMessage(error), true) } } function updateInteger(name: string, text: string, options?: { readonly composing?: boolean }): void { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) try { invalidatePreparedInteraction() setLoadState({ ...ready, snapshot: ready.runtime.session.editInteger(name, text, options) }) } catch (error) { showError(errorMessage(error), true) } } function setViewChanging(value: boolean): void { if (value) cancelPendingDocumentHashes() changingViewRef.current = value setChangingView(value) if (!value) void walletBindingRef.current?.resume() } function viewInteractionBlocked(): boolean { return changingViewRef.current || walletRefreshingRef.current || !walletAccountSynchronizedRef.current } function cancelPendingDocumentHashes(): void { if (loadState.status !== "ready") return const session = loadState.runtime.session const documentHashes = session.snapshot().documentHashes if (documentHashes === undefined) return let snapshot = loadState.snapshot for (const [name, status] of Object.entries(documentHashes)) { if (status.status === "pending") snapshot = session.cancelDocumentHash(name) } if (snapshot !== loadState.snapshot) setLoadState({ ...loadState, snapshot }) } async function calculateDocumentHash(name: string, source: "url" | "file", file?: File): Promise { if (sendingRef.current || viewInteractionBlocked()) return const ready = requireReadyState(loadState) try { const operation = ready.runtime.session.beginDocumentHash(name, source) invalidatePreparedInteraction() setLoadState({ ...ready, snapshot: operation.snapshot }) try { let digest: string if (source === "file") { digest = await calculateDocumentHashFromFile(requireDocumentFile(file), operation.signal) } else { if (operation.sourceURI === undefined) throw new Error("Cannot calculate document hash from URL: the field has no source URI.") digest = await calculateDocumentHashFromURL(operation.sourceURI, operation.signal) } const snapshot = operation.complete(digest) if (snapshot !== undefined) { invalidatePreparedInteraction() setLoadState({ ...ready, snapshot }) } } catch (error) { const snapshot = operation.fail(error instanceof Error ? error.message : "Cannot calculate document digest. Choose a file or enter it manually.") if (snapshot !== undefined) setLoadState({ ...ready, snapshot }) } } catch (error) { showError(errorMessage(error), true) } } function cancelDocumentHash(name: string): void { const ready = requireReadyState(loadState) setLoadState({ ...ready, snapshot: ready.runtime.session.cancelDocumentHash(name) }) } function commitSnapshot( ready: Extract, snapshot: CamViewerLoadedSnapshot, ): void { setLoadState({ ...ready, snapshot }) const currentUrl = new URL(window.location.href) if (snapshot.account !== undefined) currentUrl.searchParams.set("account", snapshot.account.address) try { const nextUrl = urlForView(currentUrl, snapshot) window.history.replaceState(window.history.state, "", nextUrl.href) committedUrlRef.current = nextUrl.href } catch (error) { // The read succeeded. Do not misreport it as a lookup failure merely // because its inputs cannot be represented by the URL boundary. const previousUrl = new URL(committedUrlRef.current) if (snapshot.account !== undefined) previousUrl.searchParams.set("account", snapshot.account.address) window.history.replaceState(window.history.state, "", previousUrl.href) committedUrlRef.current = previousUrl.href showStatus(`View opened, but its URL could not be updated. Refresh will open the previous view. ${errorMessage(error)}`, false) } } function arriveAtFeedback(target: FeedbackArrival["target"]): void { setFeedbackArrival({ target, interaction: interactionRevision.current }) } function showError(message: string, focus: boolean): void { setNotice({ message, kind: "error" }) if (focus) arriveAtFeedback("notice") } function showStatus(message: string, focus: boolean): void { setNotice({ message, kind: "status" }) if (focus) arriveAtFeedback("notice") } function reportTransaction(message: string, kind: Notice["kind"], focus: boolean): void { setTransactionNotice({ message, kind }) if (focus) arriveAtFeedback("transaction") } function nextInteractionRevision(): number { interactionRevision.current += 1 return interactionRevision.current } function isCurrentInteraction(revision: number): boolean { return interactionRevision.current === revision } function invalidatePreparedInteraction(): void { // Async route dispatch may still be resolving. Bump the revision whenever // viewer state can change so stale results cannot resurrect old prepared calls. interactionRevision.current += 1 setPreparedCall(undefined) } function setPreparedCall(call: CamViewerPreparedContractCall | undefined): void { preparedCallRef.current = call setPreparedCallState(call) } function nextSendRevision(): number { sendRevision.current += 1 return sendRevision.current } function isCurrentSend(revision: number): boolean { return sendRevision.current === revision } return (

{headerTitle(loadState)}

{loadState.status !== "ready" ? null : ( )}
{wallet.status === "unavailable" ? (

No injected wallet was detected.

) : ( )} {notice === undefined ? null : (

{notice.message}

)} {transactionNotice === undefined ? null : (

{transactionNotice.message}

)} {preparedCall === undefined ? null : ( )} {loadState.status === "loading" ? (
Loading CAM session...
) : null} {loadState.status === "failed" ? (

Cannot load viewer

{loadState.message}

) : null} {loadState.status === "ready" ? ( ) : null}
) } function requireDocumentFile(file: File | undefined): File { if (file === undefined) throw new Error("Choose a file to calculate its SHA-256 digest.") return file } async function waitForReceipt( ready: Extract, txHash: `0x${string}`, ): ReturnType { return await waitForSubmittedTransactionReceipt({ client: ready.runtime.publicClient, txHash, rpcEndpoint: displayRpcEndpoint(ready.runtime.startup.rpcUrl), pollingIntervalMs: RECEIPT_POLLING_INTERVAL_MS, timeoutMs: RECEIPT_WAIT_TIMEOUT_MS, }) } function headerTitle(loadState: LoadState): string { if (loadState.status === "loading") { return "Loading" } if (loadState.status === "failed") { return "CAM Viewer" } const root = loadState.snapshot.resolvedUi const title = root.element === "Screen" ? root.props.title : undefined if (typeof title !== "string") { throw new Error("resolved root UI title must be a string") } return title } function requireReadyState(loadState: LoadState): Extract { if (loadState.status !== "ready") { throw new Error("CAM viewer session is not loaded") } return loadState } function currentViewerAccount(loadState: Extract): StartupOptions["account"] { return loadState.snapshot.account !== undefined ? loadState.snapshot.account.address : loadState.runtime.startup.account }