diff --git a/public/banner.png b/public/banner.png new file mode 100644 index 0000000..2a3752b Binary files /dev/null and b/public/banner.png differ diff --git a/src/App.tsx b/src/App.tsx index 6aae4b4..ab9f23f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,25 @@ import "./styles/App.css"; import { Porto } from "porto"; -import { useEffect, useMemo, useState } from "react"; -import { type Address, type Chain, createWalletClient, custom } from "viem"; -import { getAddresses, requestAddresses } from "viem/actions"; -import { applyChainId } from "./utils/helpers.ts"; -import type { EIP1193, EIP6963AnnounceProviderEvent, EIP6963ProviderInfo } from "./utils/types.ts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type Address, + type Chain, + createWalletClient, + custom, + type TransactionReceipt, +} from "viem"; +import { getAddresses, requestAddresses, waitForTransactionReceipt } from "viem/actions"; + +import { api, applyChainId, isOk, renderJSON } from "./utils/helpers.ts"; +import type { + ApiErr, + ApiOk, + EIP1193, + EIP6963AnnounceProviderEvent, + EIP6963ProviderInfo, + PendingAny, +} from "./utils/types.ts"; declare global { interface Window { @@ -23,7 +37,173 @@ export function App() { const [providers, setProviders] = useState<{ info: EIP6963ProviderInfo; provider: EIP1193 }[]>( [], ); + const [pending, setPending] = useState(null); + const [selectedUuid, setSelectedUuid] = useState(null); + const selected = providers.find((p) => p.info.uuid === selectedUuid) ?? null; + + const [account, setAccount] = useState
(); + const [chainId, setChainId] = useState(); + const [chain, setChain] = useState(); + const [lastTxReceipt, setLastTxReceipt] = useState(null); + const [lastTxHash, setLastTxHash] = useState(null); + + const lastPendingIdRef = useRef(null); + const prevSelectedUuidRef = useRef(null); + const pollFnRef = useRef<() => void>(() => {}); + + const walletClient = useMemo(() => { + if (!selected) return undefined; + return createWalletClient({ + transport: custom(selected.provider), + chain: chain ?? undefined, + }); + }, [selected, chain]); + + const ensureServerConnected = useCallback(async () => { + try { + const resp = await api< + ApiOk<{ connected: boolean; account?: string; chainId?: number }> | ApiErr + >("/api/connection"); + + if (!isOk(resp)) return; + + const serverConnected = !!resp.data?.connected; + const serverAccount = (resp.data?.account as string | undefined)?.toLowerCase(); + const serverChainId = resp.data?.chainId as number | undefined; + + if (!account || chainId == null) { + if (serverConnected) { + await api("/api/connection", "POST", null); + } + } else { + if ( + !serverConnected || + serverAccount !== account.toLowerCase() || + serverChainId !== chainId + ) { + await api("/api/connection", "POST", [account, chainId]); + } + } + } catch {} + }, [account, chainId]); + + const pollTick = useCallback(async () => { + await ensureServerConnected(); + + try { + const resp = await api | ApiErr>("/api/transaction/request"); + + if (!isOk(resp)) { + if (pending) { + setPending(null); + lastPendingIdRef.current = null; + } + } else { + const tx = (resp as ApiOk).data; + + if (!lastPendingIdRef.current || lastPendingIdRef.current !== tx.id) { + setPending(tx); + lastPendingIdRef.current = tx.id; + setLastTxHash(null); + setLastTxReceipt(null); + } else if (!pending) { + setPending(tx); + } + } + } catch {} + }, [ensureServerConnected, pending]); + + const connect = async () => { + if (!walletClient || !selected) return; + + const addrs = (await requestAddresses(walletClient)) as readonly Address[]; + setAccount(addrs[0] as Address | undefined); + + try { + const raw = await selected.provider.request({ method: "eth_chainId" }); + applyChainId(raw, setChainId, setChain); + } catch { + setChainId(undefined); + setChain(undefined); + } + + await ensureServerConnected(); + }; + + const signAndSendCurrent = async () => { + if (!walletClient || !selected || !pending?.request) return; + + try { + const hash = (await selected.provider.request({ + method: "eth_sendTransaction", + params: [pending.request], + })) as `0x${string}`; + setLastTxHash(hash); + + const receipt = await waitForTransactionReceipt(walletClient, { hash }); + setLastTxReceipt(receipt); + + await api("/api/transaction/response", "POST", { id: pending.id, hash, error: null }); + await pollTick(); + } catch (e: unknown) { + const msg = + typeof e === "object" && + e && + "message" in e && + typeof (e as { message?: unknown }).message === "string" + ? (e as { message: string }).message + : String(e); + + console.log("send failed:", msg); + + try { + await api("/api/transaction/response", "POST", { + id: pending.id, + hash: null, + error: msg, + }); + } catch {} + + await pollTick(); + } + }; + + const resetClientState = useCallback(async () => { + setPending(null); + setLastTxHash(null); + setLastTxReceipt(null); + lastPendingIdRef.current = null; + + setAccount(undefined); + setChainId(undefined); + setChain(undefined); + try { + await api("/api/connection", "POST", null); + } catch {} + }, []); + + // Upon switching wallets, reset state. + useEffect(() => { + if ( + prevSelectedUuidRef.current && + selectedUuid && + prevSelectedUuidRef.current !== selectedUuid + ) { + void resetClientState(); + } + + prevSelectedUuidRef.current = selectedUuid; + }, [selectedUuid, resetClientState]); + + // Auto-select if only one wallet is available. + useEffect(() => { + if (providers.length === 1 && !selected) { + setSelectedUuid(providers[0].info.uuid); + } + }, [providers, selected]); + + // Listen for new provider announcements. useEffect(() => { const onAnnounce = (ev: EIP6963AnnounceProviderEvent) => { const { info, provider } = ev.detail; @@ -36,55 +216,32 @@ export function App() { window.addEventListener("eip6963:announceProvider", onAnnounce); window.dispatchEvent(new Event("eip6963:requestProvider")); - return () => { - window.removeEventListener("eip6963:announceProvider", onAnnounce); - }; + return () => window.removeEventListener("eip6963:announceProvider", onAnnounce); }, []); - const [selectedUuid, setSelectedUuid] = useState(null); - const selected = providers.find((p) => p.info.uuid === selectedUuid) ?? null; - - const [account, setAccount] = useState
(); - const [chainId, setChainId] = useState(); - const [chain, setChain] = useState(); - - const walletClient = useMemo( - () => - selected - ? createWalletClient({ - transport: custom(selected.provider), - }) - : undefined, - [selected], - ); - + // Listen for account and chain changes. useEffect(() => { if (!selected) return; const onAccountsChanged = (accounts: readonly string[]) => setAccount((accounts[0] as Address) ?? undefined); - - const onChainChanged = (raw: unknown) => { - applyChainId(raw, setChainId, setChain); - }; + const onChainChanged = (raw: unknown) => applyChainId(raw, setChainId, setChain); selected.provider.on?.("accountsChanged", onAccountsChanged); selected.provider.on?.("chainChanged", onChainChanged); - return () => { selected.provider.removeListener?.("accountsChanged", onAccountsChanged); selected.provider.removeListener?.("chainChanged", onChainChanged); }; }, [selected]); + // Upon account or chainId change, update state. useEffect(() => { (async () => { if (!selected) return; try { - const raw = await selected.provider.request({ - method: "eth_chainId", - }); + const raw = await selected.provider.request({ method: "eth_chainId" }); applyChainId(raw, setChainId, setChain); } catch { setChainId(undefined); @@ -102,67 +259,99 @@ export function App() { })(); }, [selected, walletClient]); - const connect = async () => { - if (!walletClient || !selected) return; - const addrs = (await requestAddresses(walletClient)) as readonly Address[]; - setAccount(addrs[0] as Address | undefined); + useEffect(() => { + pollFnRef.current = () => { + void pollTick(); + }; + }, [pollTick]); - try { - const raw = await selected.provider.request({ - method: "eth_chainId", - }); - applyChainId(raw, setChainId, setChain); - } catch { - setChainId(undefined); - setChain(undefined); - } - }; + // Polling loop to check for new pending transactions. + useEffect(() => { + pollFnRef.current(); + + const id = window.setInterval(() => { + pollFnRef.current(); + }, 1000); + + return () => { + window.clearInterval(id); + }; + }, []); return ( -
-

Foundry

- - {providers.length > 1 && ( -
- -
- )} - - {providers.length === 0 &&

No wallets found.

} - - {selected && account && ( -
-          {`\
-chain:  ${chain ? `${chain.name} (${chainId})` : (chainId ?? "unknown")}
-rpc:    ${chain?.rpcUrls?.default?.http?.[0] ?? chain?.rpcUrls?.public?.http?.[0] ?? "unknown"}`}
-        
- )} - - {selected ? ( - account ? ( -
Connected: {account}
- ) : ( -
+ )} + + {providers.length === 0 &&

No wallets found.

} + + {selected && account && ( + <> +
Connected
+
+              {`\
+account: ${account}
+chain:   ${chain ? `${chain.name} (${chainId})` : (chainId ?? "unknown")}
+rpc:     ${chain?.rpcUrls?.default?.http?.[0] ?? chain?.rpcUrls?.public?.http?.[0] ?? "unknown"}`}
+            
+ + )} + + {selected && !account && ( + - ) - ) : ( -

Please select a wallet

- )} + )} + + {selected && account && ( + <> +
To Sign
+
+
{pending ? renderJSON(pending) : "No pending transaction"}
+
+ + )} + + {selected && account && lastTxHash && ( + <> +
Transaction Hash
+
{lastTxHash}
+ +
+
Receipt
+
+                {lastTxReceipt ? renderJSON(lastTxReceipt) : "Waiting for receipt..."}
+              
+
+ + )} + + {selected && account && pending && ( + + )} + ); } diff --git a/src/styles/App.css b/src/styles/App.css index 6920760..868bfcf 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -1,9 +1,39 @@ -.container { +.wrapper { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; + padding: 24px; +} + +.container { + display: flex; + flex-direction: column; + align-items: flex-start; + background-color: #3b3b3b; + padding: 16px; + max-width: 600px; + border-radius: 8px; +} + +.banner { + width: 100%; + border-radius: 8px; + height: auto; +} + +.wallet-selector { + align-self: center; + margin-top: 16px; +} + +.wallet-connect { + align-self: center; +} + +.wallet-send { + align-self: center; } .title { @@ -12,14 +42,16 @@ margin-bottom: 24px; } -.info { - border: 1px solid #e1e4e8; - border-radius: 6px; - padding: 8px 12px; - font-size: 13px; +.section-title { + font-size: 24px; + color: #f8f8f8; margin-bottom: 16px; } -.disconnect { - margin-top: 16px; +.box { + font-size: 13px; + margin-bottom: 16px; + border: 1px solid #e1e4e8; + border-radius: 8px; + padding: 8px 12px; } diff --git a/src/styles/global.css b/src/styles/global.css index 327f24d..ff20795 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3,7 +3,6 @@ body, #root { width: 100%; height: 100%; - overflow: hidden; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; @@ -12,8 +11,8 @@ body, } button { + border: 1px solid #e1e4e8; padding: 8px 12px; - border: none; border-radius: 4px; background-color: #3a3f51; color: #f8f8f8; @@ -25,16 +24,22 @@ button:hover { } select { - margin-left: 8px; - padding: 4px; - border-radius: 4px; - border: 1px solid #444; + padding: 8px; + border-radius: 8px; + border: 1px solid #e1e4e8; background-color: #1e2026; color: #f8f8f8; margin-bottom: 16px; + cursor: pointer; } option { background-color: #1e2026; color: #f8f8f8; } + +pre { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 158a427..6b0f0b5 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,29 +1,23 @@ -import type { Chain } from "viem"; +import { type Chain, hexToBigInt } from "viem"; import * as chains from "viem/chains"; -const ALL_CHAINS: readonly Chain[] = Object.freeze(Object.values(chains) as Chain[]); +import type { ApiErr, ApiOk } from "./types"; -const getChainById = (id: number) => ALL_CHAINS.find((c) => c.id === id); +export const ALL_CHAINS: readonly Chain[] = Object.freeze(Object.values(chains) as Chain[]); +export const getChainById = (id: number) => ALL_CHAINS.find((c) => c.id === id); const parseChainId = (input: unknown): number | undefined => { - if (typeof input === "number") { - return Number.isFinite(input) ? input : undefined; - } - + if (typeof input === "number") return Number.isFinite(input) ? input : undefined; if (typeof input !== "string") return undefined; - const s = input.trim(); - if (/^0x[0-9a-fA-F]+$/.test(s)) { const n = Number.parseInt(s, 16); return Number.isNaN(n) ? undefined : n; } - if (/^\d+$/.test(s)) { const n = Number.parseInt(s, 10); return Number.isNaN(n) ? undefined : n; } - return undefined; }; @@ -36,3 +30,32 @@ export const applyChainId = ( setChainId(id); setChain(id != null ? getChainById(id) : undefined); }; + +export const toBig = (h?: `0x${string}`) => (h ? hexToBigInt(h) : undefined); + +export const ENDPOINT = "http://127.0.0.1:9545"; + +export const api = async ( + path: string, + method: "GET" | "POST" = "GET", + body?: unknown, +): Promise => { + const res = await fetch(`${ENDPOINT}${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) throw new Error(`API request failed: ${res.status} ${res.statusText}`); + try { + return (await res.json()) as T; + } catch { + throw new Error("Invalid JSON response"); + } +}; + +export const renderJSON = (obj: unknown) => + JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2); + +export const isOk = (r: ApiOk | ApiErr | null | undefined): r is ApiOk => { + return !!r && (r as ApiOk).status === "ok"; +}; diff --git a/src/utils/types.ts b/src/utils/types.ts index b88f6b9..a9f402f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,3 +1,5 @@ +import type { TransactionRequest } from "viem"; + export type EIP6963ProviderInfo = { uuid: string; name: string; @@ -37,3 +39,7 @@ declare global { "eip6963:requestProvider": Event; } } + +export type ApiOk = { status: "ok"; data: T }; +export type ApiErr = { status: string; message?: string }; +export type PendingAny = Record & { id: string; request: TransactionRequest }; diff --git a/vite.config.ts b/vite.config.ts index 0fff228..e088665 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,23 +3,7 @@ import { defineConfig } from "vite"; import mkcert from "vite-plugin-mkcert"; export default defineConfig({ - plugins: [ - mkcert(), - react(), - { - name: "add-session-token-banner", - generateBundle(_, bundle) { - for (const file of Object.values(bundle)) { - if (file.type === "chunk" && file.fileName === "main.js") { - file.code = `/** SESSION_TOKEN */\n\n${file.code}`; - } - } - }, - }, - ], - server: { - port: 9545, - }, + plugins: [mkcert(), react()], build: { outDir: "dist", assetsDir: ".",