diff --git a/website/components/FacilitatorApproval.tsx b/website/components/FacilitatorApproval.tsx new file mode 100644 index 000000000..5829bac54 --- /dev/null +++ b/website/components/FacilitatorApproval.tsx @@ -0,0 +1,370 @@ +import React, { useEffect, useState } from "react"; +import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt, useSwitchChain } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; +import { css } from "../styled-system/css"; +import { getUSDCConfig, fromCAIP2, type USDCConfig } from "@fretchen/chain-utils"; + +// Minimal ERC-20 ABI for allowance + approve +export const ERC20_ABI = [ + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "approve", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +// Supported networks for approval +export const APPROVAL_NETWORKS = [ + { network: "eip155:10", label: "Optimism" }, + { network: "eip155:8453", label: "Base" }, +] as const; + +export const APPROVAL_NETWORKS_WITH_TESTNETS = [ + ...APPROVAL_NETWORKS, + { network: "eip155:11155420", label: "OP Sepolia" }, + { network: "eip155:84532", label: "Base Sepolia" }, +] as const; + +// Preset approval amounts +const PRESETS = [ + { label: "1 USDC", value: "1" }, + { label: "10 USDC", value: "10" }, +]; + +/** Resolve the USDC config for a CAIP-2 network string. Returns null if unsupported. */ +export function getNetworkUSDCConfig(network: string): USDCConfig | null { + try { + return getUSDCConfig(network); + } catch { + return null; + } +} + +// ─── Styles ────────────────────────────────────────────────────────────────── + +const container = css({ + border: "1px solid token(colors.border, #e5e7eb)", + borderRadius: "8px", + padding: "20px", + marginBottom: "6", + backgroundColor: "token(colors.codeBg, #f9fafb)", +}); + +const statusRow = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: "8px", + marginBottom: "4", +}); + +const label = css({ + fontSize: "sm", + color: "#6b7280", + fontWeight: "medium", +}); + +const valueText = css({ + fontSize: "lg", + fontWeight: "semibold", +}); + +const approveRow = css({ + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", +}); + +const presetButton = css({ + padding: "6px 12px", + fontSize: "sm", + borderRadius: "6px", + border: "1px solid token(colors.border, #d1d5db)", + backgroundColor: "white", + cursor: "pointer", + fontWeight: "medium", + transition: "all 0.15s", + _hover: { + backgroundColor: "#f3f4f6", + borderColor: "#9ca3af", + }, + _disabled: { + opacity: 0.5, + cursor: "not-allowed", + }, +}); + +const activeButton = css({ + backgroundColor: "#2563eb", + color: "white", + borderColor: "#2563eb", + _hover: { + backgroundColor: "#1d4ed8", + }, +}); + +const selectedNetworkButton = css({ + backgroundColor: "#1e293b", + color: "white", + borderColor: "#1e293b", + _hover: { + backgroundColor: "#334155", + }, +}); + +const txStatus = css({ + fontSize: "sm", + marginTop: "3", + padding: "8px 12px", + borderRadius: "6px", +}); + +const connectHint = css({ + fontSize: "sm", + color: "#6b7280", + textAlign: "center", + padding: "12px", +}); + +const networkRow = css({ + display: "flex", + alignItems: "center", + gap: "8px", + flexWrap: "wrap", + marginBottom: "4", +}); + +// ─── Component ─────────────────────────────────────────────────────────────── + +interface FacilitatorApprovalProps { + facilitatorAddress?: Address | null; + showTestnets?: boolean; +} + +export function FacilitatorApproval({ + facilitatorAddress: propAddress, + showTestnets = false, +}: FacilitatorApprovalProps) { + const { address, isConnected, chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const [facilitatorAddress, setFacilitatorAddress] = useState
(propAddress ?? null); + const [fetchError, setFetchError] = useState(null); + + const networks = showTestnets ? APPROVAL_NETWORKS_WITH_TESTNETS : APPROVAL_NETWORKS; + const [selectedNetwork, setSelectedNetwork] = useState(networks[0].network); + + const usdcConfig = getNetworkUSDCConfig(selectedNetwork); + const targetChainId = usdcConfig ? usdcConfig.chainId : fromCAIP2(selectedNetwork); + + // Fetch facilitator address from /supported if not provided via props + useEffect(() => { + if (propAddress) { + setFacilitatorAddress(propAddress); + return; + } + + const controller = new AbortController(); + fetch("https://facilitator.fretchen.eu/supported", { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json) => { + // Extract facilitator address from fee extension + const feeExt = json.extensions?.find((ext: Record) => ext.name === "facilitator_fee"); + const recipient = (feeExt as Record>)?.fee?.recipient; + if (recipient) { + setFacilitatorAddress(recipient as Address); + } else { + setFetchError("Facilitator address not found in /supported response"); + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + setFetchError(err.message); + } + }); + return () => controller.abort(); + }, [propAddress]); + + // Read current allowance — uses the correct USDC address for the selected network + const { + data: allowance, + isLoading: isReadingAllowance, + refetch: refetchAllowance, + } = useReadContract({ + address: usdcConfig?.address as Address, + abi: ERC20_ABI, + functionName: "allowance", + args: address && facilitatorAddress ? [address, facilitatorAddress] : undefined, + chainId: targetChainId, + query: { + enabled: !!address && !!facilitatorAddress && !!usdcConfig, + }, + }); + + // Write approve + const { writeContract, isPending: isApproving, data: txHash } = useWriteContract(); + // Track the chain where the tx was sent so receipt polling stays on the correct chain + const [txChainId, setTxChainId] = useState(undefined); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash: txHash, + chainId: txChainId, + }); + + // Refetch allowance after successful approval + useEffect(() => { + if (isSuccess) { + const timer = setTimeout(() => refetchAllowance(), 2000); + return () => clearTimeout(timer); + } + }, [isSuccess, refetchAllowance]); + + const handleApprove = async (amount: string) => { + if (!facilitatorAddress || !address || !usdcConfig) return; + + // Switch chain if needed + if (chainId !== targetChainId) { + try { + await switchChainAsync({ chainId: targetChainId }); + } catch { + return; // User rejected switch + } + } + + // Record which chain the tx is sent on for receipt tracking + setTxChainId(targetChainId); + + writeContract({ + address: usdcConfig.address, + abi: ERC20_ABI, + functionName: "approve", + args: [facilitatorAddress, parseUnits(amount, usdcConfig.decimals)], + chainId: targetChainId, + }); + }; + + // ─── Render ────────────────────────────────────────────────────────── + + if (fetchError) { + return ( +
+

Could not load facilitator address: {fetchError}

+
+ ); + } + + if (!isConnected) { + return ( +
+

Connect your wallet to check and manage your USDC approval for the facilitator.

+
+ ); + } + + if (!usdcConfig) { + return ( +
+

USDC is not available on the selected network.

+
+ ); + } + + const formattedAllowance = allowance !== undefined ? formatUnits(allowance as bigint, usdcConfig.decimals) : "—"; + + const hasAllowance = allowance !== undefined && (allowance as bigint) > 0n; + + return ( +
+ {/* Network selector */} +

+ Network: +

+
+ {networks.map((net) => ( + + ))} +
+ + {/* Allowance display */} +
+
+

Your current USDC approval on {usdcConfig.name}

+

+ {isReadingAllowance ? "Loading…" : `${formattedAllowance} USDC`} +

+
+ {facilitatorAddress && ( +
+

Facilitator address

+

{facilitatorAddress}

+
+ )} +
+ + {/* USDC contract info */} +

+ USDC on {usdcConfig.name}: {usdcConfig.address} +

+ + {/* Approve buttons */} +

+ Approve USDC spending: +

+
+ {PRESETS.map((preset) => ( + + ))} + +
+ + {(isApproving || isConfirming) && ( +
+ {isApproving ? "⏳ Confirm in your wallet…" : "⏳ Waiting for confirmation…"} +
+ )} + + {isSuccess && ( +
+ ✓ Approval updated successfully +
+ )} +
+ ); +} diff --git a/website/layouts/LayoutDefault.tsx b/website/layouts/LayoutDefault.tsx index 4d0402a42..c289a839a 100644 --- a/website/layouts/LayoutDefault.tsx +++ b/website/layouts/LayoutDefault.tsx @@ -59,10 +59,7 @@ export default function LayoutDefault({ children }: { children: React.ReactNode Quantum
- ImageGen -
-
- AI Assistent + Lab
diff --git a/website/pages/index/+Page.tsx b/website/pages/index/+Page.tsx index ff2fafe40..819fd1967 100644 --- a/website/pages/index/+Page.tsx +++ b/website/pages/index/+Page.tsx @@ -26,9 +26,9 @@ export default function Page() { diff --git a/website/pages/lab/+Head.tsx b/website/pages/lab/+Head.tsx new file mode 100644 index 000000000..7029a2790 --- /dev/null +++ b/website/pages/lab/+Head.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { usePageContext } from "vike-react/usePageContext"; +import { generateBreadcrumbSchema } from "../../utils/schemaOrg"; +import { description as getDescription } from "./+description"; +import { title as getTitle } from "./+title"; + +export default function Head() { + const pageContext = usePageContext(); + const url = `https://www.fretchen.eu${pageContext.urlPathname}`; + const title = getTitle(); + const description = getDescription(); + + const collectionSchema = { + "@context": "https://schema.org", + "@type": "CollectionPage", + name: "Lab — Blockchain Experiments", + description, + url, + author: { + "@type": "Person", + name: "fretchen", + url: "https://www.fretchen.eu", + }, + hasPart: [ + { + "@type": "WebPage", + name: "AI Image Generator", + url: "https://www.fretchen.eu/imagegen", + }, + { + "@type": "WebPage", + name: "AI Assistant", + url: "https://www.fretchen.eu/assistent", + }, + { + "@type": "WebPage", + name: "x402 Payments & Facilitator", + url: "https://www.fretchen.eu/x402", + }, + { + "@type": "WebPage", + name: "Agent Onboarding", + url: "https://www.fretchen.eu/agent-onboarding", + }, + ], + }; + + const breadcrumbSchema = generateBreadcrumbSchema([ + { name: "Home", url: "https://www.fretchen.eu" }, + { name: "Lab", url }, + ]); + + return ( + <> + + + + + + + + + + +