diff --git a/.env.example b/.env.example index 782fde1..5ac37da 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,11 @@ AMOUNT_IN=5000000 SLIPPAGE_BPS=100 DEADLINE_MINUTES=15 # Browser-visible local configuration; these values are not secrets. +VITE_APP_MODE=local VITE_CHAIN_ID=31337 VITE_RPC_URL=http://127.0.0.1:8545 VITE_SWAP_GUARD_ADDRESS= VITE_TOKEN_IN_ADDRESS=0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9 VITE_TOKEN_OUT_ADDRESS=0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1 +VITE_BLOCK_EXPLORER_URL= +VITE_GITHUB_URL=https://github.com/alsaecas/swapguard diff --git a/.gitignore b/.gitignore index 120d731..60e4eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ broadcast/ node_modules/ web/node_modules/ web/dist/ +web/.vercel/ +.playwright-cli/ +output/ *.tsbuildinfo web/vite.config.js web/vite.config.d.ts diff --git a/README.md b/README.md index d041068..585d276 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ SwapGuard is an educational Uniswap V2 integration built with Solidity, Foundry, React, TypeScript, viem, and wagmi. It demonstrates exact-input ERC-20 swaps with router quotations, bounded slippage, deadlines, direct and multi-hop paths, temporary allowances, mock tests, and a reproducible Arbitrum fork. +**[Open the live portfolio demo](https://swapguard-zeta.vercel.app/)** · [Review the contract](src/SwapGuard.sol) · [Read the architecture](docs/ARCHITECTURE.md) + > SwapGuard has not been audited. It is a portfolio project, not a recommendation to manage meaningful funds. ## What this project proves @@ -86,6 +88,8 @@ forge test --match-path 'test/fork/**' -vvv | Variable | Purpose | |---|---| +| `VITE_APP_MODE` | Frontend mode: `portfolio` (default), `local`, or `testnet` | +| `VITE_GITHUB_URL` | Public source link shown by the frontend | | `ARBITRUM_RPC_URL` | Archive RPC used at pinned block `250000000` | | `PRIVATE_KEY` | Local Anvil account used by scripts; never use a real key | | `ROUTER_ADDRESS` | Validated router passed to deployment | @@ -95,9 +99,18 @@ forge test --match-path 'test/fork/**' -vvv | `VITE_CHAIN_ID`, `VITE_RPC_URL` | Browser-visible local chain configuration | | `VITE_SWAP_GUARD_ADDRESS` | Browser-visible local deployment | | `VITE_TOKEN_IN_ADDRESS`, `VITE_TOKEN_OUT_ADDRESS` | Browser token configuration | +| `VITE_BLOCK_EXPLORER_URL` | Optional explorer base URL for live transaction links | Anvil default keys are public. They must never be used on any real network. +### Frontend modes + +- `portfolio` is the public Vercel experience. It needs no wallet, contract, or RPC configuration and uses a clearly labelled deterministic preview; it never submits a transaction. +- `local` enables wallet and contract interaction only when every required public variable is complete and valid. It is intended for the reproducible Anvil fork below. +- `testnet` uses the same strict live-mode checks, but is deliberately not configured in the hosted deployment because no router, liquid token pair, faucet path, and deployed SwapGuard instance have all been verified together. + +The Vercel project builds from `web/` with `npm ci`, `npm run build`, and the `dist/` output. Production and preview deployments set `VITE_APP_MODE=portfolio`; no private key or server-side secret is used by the site. + ## Reproducible local demo Use separate terminals. The first command stays in the foreground and exits with Ctrl-C. @@ -129,7 +142,9 @@ Mocks test failure modes and invariants without RPC access. Fork tests call the | Exact transfer and allowance cleanup | Unit/fuzz/fork | unit/fork suites | | Expired deadline and impossible minimum | Unit/fork | unit/fork suites | | Real router integration | Fork | `test/fork/SwapGuardArbitrumFork.t.sol` | +| Modes and environment validation | Vitest | `web/src/config/env.test.ts` | | Parsing, formatting, deadline, route | Vitest | `web/src/lib/utils.test.ts` | +| Deterministic preview and quote invalidation | Vitest | `web/src/lib/portfolio.test.ts` | ## Security considerations diff --git a/VERCEL_IMPLEMENTATION_PLAN.md b/VERCEL_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..7bcdbaa --- /dev/null +++ b/VERCEL_IMPLEMENTATION_PLAN.md @@ -0,0 +1,12 @@ +# Vercel Portfolio Deployment Plan + +1. Introduce a typed `portfolio | local | testnet` configuration layer that defaults safely to portfolio mode and enables chain hooks only with complete, nonzero live configuration. +2. Split the frontend into focused portfolio, swap, architecture, testing, security, header, and footer components while preserving the real local Anvil transaction flow. +3. Add a clearly labeled, non-transactional portfolio preview using deterministic illustrative data, plus real repository, architecture, security, and local-setup links. +4. Improve wallet/network handling, quote invalidation, contract bytecode checks, receipt states, errors, and transaction explorer/copy behavior for live-enabled modes. +5. Add environment/mode, URL, validation, preview, and disabled-state tests; update metadata, favicon, `.env.example`, README, and Vercel configuration. +6. Run all required contract/frontend checks and inspect the production bundle for secrets. +7. Create or reuse only the verified `swapguard` Vercel project in the authenticated `alsaecas-projects` team, deploy and validate a preview, then publish the branch and open a PR. +8. Merge only after GitHub checks and preview verification pass; verify the production deployment from `main`, update repository metadata, and report all unverified RPC-dependent work honestly. + +No public testnet mode will be enabled unless router, tokens, liquidity, deployed SwapGuard bytecode, faucet, RPC, explorer, and end-to-end transactions are all independently verified. diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..245259b --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +.vercel +.env* diff --git a/web/index.html b/web/index.html index 44af71e..09f0647 100644 --- a/web/index.html +++ b/web/index.html @@ -3,8 +3,20 @@ - - SwapGuard + + + + + + + + + + + + + + SwapGuard — Secure Uniswap V2 Integration
diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..7ad84f3 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/public/social-card.svg b/web/public/social-card.svg new file mode 100644 index 0000000..f30f5b9 --- /dev/null +++ b/web/public/social-card.svg @@ -0,0 +1 @@ +SSwapGuardSecure swap integration, made inspectable.Slippage protection · Temporary allowances · Pinned fork testsSOLIDITY 0.8.24 / FOUNDRY / REACT / VIEM / WAGMI diff --git a/web/src/App.tsx b/web/src/App.tsx index d40ccf1..395fd1e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,86 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; -import type { Address } from "viem"; -import { useAccount, useConnect, useDisconnect, useReadContract, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; -import { localAnvil } from "./config/chains"; -import { erc20Abi, swapGuardAbi, swapGuardAddress } from "./config/contracts"; -import { tokens } from "./config/tokens"; -import { parseTokenAmount, formatTokenAmount } from "./lib/amounts"; -import { createDeadline } from "./lib/deadline"; -import { shortenAddress, formatRoute } from "./lib/formatting"; -import { minimumOutput, slippageToBps } from "./lib/slippage"; +import { appConfig, type LiveConfig } from "./config/env"; +import { PortfolioPage } from "./components/PortfolioPage"; +import { LiveApp } from "./components/LiveApp"; +import { ConfigurationErrorPage } from "./components/ConfigurationErrorPage"; export default function App() { - const { address, chainId, isConnected } = useAccount(); - const { connect, connectors, isPending: connecting } = useConnect(); - const { disconnect } = useDisconnect(); - const [inputIndex, setInputIndex] = useState(0); - const [amountText, setAmountText] = useState(""); - const [slippage, setSlippage] = useState("1"); - const [customSlippage, setCustomSlippage] = useState("2"); - const [deadlineMinutes, setDeadlineMinutes] = useState(15); - const [quote, setQuote] = useState(); - const [status, setStatus] = useState("Enter an amount"); - const input = tokens[inputIndex]; - const output = tokens[1 - inputIndex]; - const path = [input.address, output.address] as Address[]; - const amountIn = useMemo(() => { try { return parseTokenAmount(amountText, input.decimals); } catch { return undefined; } }, [amountText, input.decimals]); - const selectedSlippage = slippage === "custom" ? customSlippage : slippage; - const bps = useMemo(() => { try { return slippageToBps(selectedSlippage); } catch { return undefined; } }, [selectedSlippage]); - const minimum = quote !== undefined && bps !== undefined ? minimumOutput(quote, bps) : undefined; - const enabled = Boolean(address && amountIn); - const { data: balance, refetch: refetchBalance } = useReadContract({ abi: erc20Abi, address: input.address, functionName: "balanceOf", args: [address!], query: { enabled: Boolean(address) } }); - const { data: outputBalance, refetch: refetchOutputBalance } = useReadContract({ abi: erc20Abi, address: output.address, functionName: "balanceOf", args: [address!], query: { enabled: Boolean(address) } }); - const { data: allowance = 0n, refetch: refetchAllowance } = useReadContract({ abi: erc20Abi, address: input.address, functionName: "allowance", args: [address!, swapGuardAddress], query: { enabled: Boolean(address) } }); - const quoteRead = useReadContract({ abi: swapGuardAbi, address: swapGuardAddress, functionName: "quoteExactInput", args: amountIn ? [amountIn, path] : undefined, query: { enabled: false } }); - const approval = useWriteContract(); - const swap = useWriteContract(); - const approvalReceipt = useWaitForTransactionReceipt({ hash: approval.data }); - const swapReceipt = useWaitForTransactionReceipt({ hash: swap.data }); - const needsApproval = amountIn !== undefined && allowance < amountIn; - const busy = approval.isPending || approvalReceipt.isLoading || swap.isPending || swapReceipt.isLoading; - - async function fetchQuote() { - if (!amountIn) return; - setStatus("Fetching quote"); - try { const result = await quoteRead.refetch(); const value = result.data?.[1]; if (!value) throw new Error(); setQuote(value); setStatus("Review swap"); } - catch { setQuote(undefined); setStatus("Quote unavailable"); } - } - async function approve() { - if (!amountIn) return; - setStatus(`Waiting for approval signature`); - approval.writeContract({ abi: erc20Abi, address: input.address, functionName: "approve", args: [swapGuardAddress, amountIn] }, { onSuccess: () => setStatus("Approval pending"), onError: () => setStatus("Transaction rejected") }); + if (!appConfig.liveEnabled) { + if (appConfig.mode !== "portfolio") return ; + return ; } - async function executeSwap() { - if (!address || !amountIn || !minimum) return; - setStatus("Waiting for swap signature"); - swap.writeContract({ abi: swapGuardAbi, address: swapGuardAddress, functionName: "swapExactInput", args: [{ amountIn, amountOutMin: minimum, path, recipient: address, deadline: createDeadline(deadlineMinutes) }] }, { onSuccess: () => setStatus("Swap pending"), onError: () => setStatus("Swap failed") }); - } - useEffect(() => { - if (approvalReceipt.isSuccess) { - setStatus("Approval confirmed"); - void refetchAllowance(); - } - }, [approvalReceipt.isSuccess, refetchAllowance]); - useEffect(() => { - if (swapReceipt.isSuccess) { - setStatus("Swap confirmed"); - void refetchBalance(); - void refetchOutputBalance(); - } - }, [swapReceipt.isSuccess, refetchBalance, refetchOutputBalance]); - const reverse = () => { setInputIndex(1 - inputIndex); setAmountText(""); setQuote(undefined); setStatus("Enter an amount"); }; - - return
LOCAL FORK LAB

SwapGuard

{isConnected ? : }
-

Secure exact-input swaps with transparent quotations, bounded slippage, and transaction deadlines.

- {isConnected && chainId !== localAnvil.id && } -
{ setAmountText(event.target.value); setQuote(undefined); setStatus("Enter an amount"); }} />
- -
{quote === undefined ? "—" : formatTokenAmount(quote, output.decimals)}
-
- {quote !== undefined &&
Route
{formatRoute([input, output])}
Minimum received
{minimum === undefined ? "Invalid slippage" : `${formatTokenAmount(minimum, output.decimals)} ${output.symbol}`}
Selected slippage
{selectedSlippage}%
Rate
1 {input.symbol} ≈ {amountIn ? formatTokenAmount((quote * 10n ** BigInt(input.decimals)) / amountIn, output.decimals) : "—"} {output.symbol}
} - {!isConnected ? : quote === undefined ? : needsApproval ? : } -

{status}

{(approval.data || swap.data) && Transaction: {shortenAddress((swap.data || approval.data)!)}} -
-

Before you swap

A quote is an estimate, not an oracle. Minimum received applies your selected slippage tolerance; price can move before confirmation. The deadline prevents late execution. Approval lets SwapGuard transfer only this exact input amount.

-
; + return ; } diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx new file mode 100644 index 0000000..1f0a878 --- /dev/null +++ b/web/src/components/AppHeader.tsx @@ -0,0 +1,8 @@ +type Props = { githubUrl: string; mode: "portfolio" | "local" | "testnet"; children?: React.ReactNode }; + +export function AppHeader({ githubUrl, mode, children }: Props) { + return
+ SSwapGuard +
{mode === "portfolio" ? "Portfolio demo" : `${mode} mode`}{children}View source
+
; +} diff --git a/web/src/components/ArchitectureSection.tsx b/web/src/components/ArchitectureSection.tsx new file mode 100644 index 0000000..b7fcc95 --- /dev/null +++ b/web/src/components/ArchitectureSection.tsx @@ -0,0 +1,2 @@ +const nodes = [["Wallet", "Approve exact input"], ["SwapGuard", "Validate + temporary allowance"], ["V2 Router", "Route through pools"], ["Recipient", "Receive output directly"]]; +export function ArchitectureSection() { return
Architecture

Output goes where it belongs.

The adapter coordinates the swap without intentionally retaining user output or granting unlimited router authority.

{nodes.map(([title, copy], index) =>
0{index + 1}

{title}

{copy}

{index < nodes.length - 1 && }
)}
Explore the complete architecture ↗
; } diff --git a/web/src/components/ConfigurationErrorPage.tsx b/web/src/components/ConfigurationErrorPage.tsx new file mode 100644 index 0000000..016f820 --- /dev/null +++ b/web/src/components/ConfigurationErrorPage.tsx @@ -0,0 +1,7 @@ +import type { AppConfig } from "../config/env"; +import { AppHeader } from "./AppHeader"; +import { Footer } from "./Footer"; + +export function ConfigurationErrorPage({ config }: { config: AppConfig }) { + return
!Live mode unavailable

SwapGuard is not configured for this environment.

Start the local Anvil fork, deploy the contract, and provide every required public frontend variable before connecting a wallet.

    {config.errors.map((error) =>
  • {error}
  • )}
; +} diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx new file mode 100644 index 0000000..4d1eae8 --- /dev/null +++ b/web/src/components/Footer.tsx @@ -0,0 +1 @@ +export function Footer({ githubUrl }: { githubUrl: string }) { return ; } diff --git a/web/src/components/HeroSection.tsx b/web/src/components/HeroSection.tsx new file mode 100644 index 0000000..52890ee --- /dev/null +++ b/web/src/components/HeroSection.tsx @@ -0,0 +1,6 @@ +export function HeroSection({ githubUrl }: { githubUrl: string }) { + return
+
Secure DEX integration, made inspectable

Token swaps with guardrails you can see.

SwapGuard is a focused Uniswap V2 integration demonstrating ERC-20 approvals, slippage protection, exact temporary allowances, and reproducible Arbitrum fork testing.

No real fundsUnaudited portfolio projectSolidity 0.8.24
+
SGSWAPGUARD
Wallet
V2 Router
amountOutMin ✓
+
; +} diff --git a/web/src/components/LiveApp.tsx b/web/src/components/LiveApp.tsx new file mode 100644 index 0000000..458f5b0 --- /dev/null +++ b/web/src/components/LiveApp.tsx @@ -0,0 +1,8 @@ +import type { LiveConfig } from "../config/env"; +import { AppHeader } from "./AppHeader"; +import { LiveSwapCard } from "./LiveSwapCard"; +import { Footer } from "./Footer"; + +export function LiveApp({ config }: { config: LiveConfig }) { + return
Real transaction mode

{config.mode === "local" ? "Reproducible local fork" : "Verified testnet"}

{config.mode === "local" ? "This mode connects to your configured Anvil fork. It can request real router quotations and submit local-only approval and swap transactions." : "This mode uses the explicitly configured public testnet deployment."}

; +} diff --git a/web/src/components/LiveSwapCard.tsx b/web/src/components/LiveSwapCard.tsx new file mode 100644 index 0000000..d738a07 --- /dev/null +++ b/web/src/components/LiveSwapCard.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Address, Hash } from "viem"; +import { useAccount, useBytecode, useConnect, useDisconnect, useReadContract, useSwitchChain, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import type { LiveConfig } from "../config/env"; +import { createTokens } from "../config/tokens"; +import { erc20Abi, swapGuardAbi } from "../config/contracts"; +import { parseTokenAmount, formatTokenAmount } from "../lib/amounts"; +import { createDeadline } from "../lib/deadline"; +import { shortenAddress, formatRoute } from "../lib/formatting"; +import { minimumOutput, slippageToBps } from "../lib/slippage"; +import { isQuoteCurrent, quoteIdentityKey } from "../lib/quoteState"; +import { transactionExplorerUrl } from "../lib/explorer"; + +export function LiveSwapCard({ config }: { config: LiveConfig }) { + const tokens = useMemo(() => createTokens(config), [config]); + const { address, chainId, isConnected } = useAccount(); + const { connect, connectors, isPending: connecting, error: connectionError } = useConnect(); + const { disconnect } = useDisconnect(); + const { switchChain, isPending: switching, error: switchError } = useSwitchChain(); + const [inputIndex, setInputIndex] = useState(0); + const [amountText, setAmountText] = useState(""); + const [slippage, setSlippage] = useState("1"); + const [customSlippage, setCustomSlippage] = useState("2"); + const [deadlineMinutes, setDeadlineMinutes] = useState(15); + const [quote, setQuote] = useState(); + const [quotedKey, setQuotedKey] = useState(); + const [status, setStatus] = useState("Enter an amount"); + const [copied, setCopied] = useState(false); + const input = tokens[inputIndex]; + const output = tokens[1 - inputIndex]; + const path = [input.address, output.address] as Address[]; + const selectedSlippage = slippage === "custom" ? customSlippage : slippage; + const amountIn = useMemo(() => { try { return parseTokenAmount(amountText, input.decimals); } catch { return undefined; } }, [amountText, input.decimals]); + const bps = useMemo(() => { try { return slippageToBps(selectedSlippage); } catch { return undefined; } }, [selectedSlippage]); + const identity = useMemo(() => ({ amount: amountText, inputIndex, slippage: selectedSlippage, chainId, account: address, contract: config.contractAddress }), [amountText, inputIndex, selectedSlippage, chainId, address, config.contractAddress]); + const quoteIsCurrent = isQuoteCurrent(quotedKey, identity); + const currentQuote = quoteIsCurrent ? quote : undefined; + const minimum = currentQuote !== undefined && bps !== undefined ? minimumOutput(currentQuote, bps) : undefined; + const wrongNetwork = isConnected && chainId !== config.chainId; + + const bytecode = useBytecode({ address: config.contractAddress, chainId: config.chainId, query: { retry: false } }); + const contractReady = bytecode.data !== undefined && bytecode.data !== "0x"; + const contractUnavailable = bytecode.isError || bytecode.data === "0x"; + const readsEnabled = Boolean(address && contractReady && !wrongNetwork); + const { data: balance, refetch: refetchBalance } = useReadContract({ abi: erc20Abi, address: input.address, functionName: "balanceOf", args: address ? [address] : undefined, chainId: config.chainId, query: { enabled: readsEnabled, retry: false } }); + const { data: outputBalance, refetch: refetchOutputBalance } = useReadContract({ abi: erc20Abi, address: output.address, functionName: "balanceOf", args: address ? [address] : undefined, chainId: config.chainId, query: { enabled: readsEnabled, retry: false } }); + const { data: allowance = 0n, refetch: refetchAllowance } = useReadContract({ abi: erc20Abi, address: input.address, functionName: "allowance", args: address ? [address, config.contractAddress] : undefined, chainId: config.chainId, query: { enabled: readsEnabled, retry: false } }); + const quoteRead = useReadContract({ abi: swapGuardAbi, address: config.contractAddress, functionName: "quoteExactInput", args: amountIn ? [amountIn, path] : undefined, chainId: config.chainId, query: { enabled: false, retry: false } }); + const approval = useWriteContract(); + const swap = useWriteContract(); + const approvalReceipt = useWaitForTransactionReceipt({ hash: approval.data, chainId: config.chainId, query: { retry: false } }); + const swapReceipt = useWaitForTransactionReceipt({ hash: swap.data, chainId: config.chainId, query: { retry: false } }); + const transactionHash = (swap.data || approval.data) as Hash | undefined; + const transactionUrl = transactionHash ? transactionExplorerUrl(config.explorerUrl, transactionHash) : undefined; + const needsApproval = amountIn !== undefined && allowance < amountIn; + const busy = approval.isPending || approvalReceipt.isLoading || swap.isPending || swapReceipt.isLoading; + + useEffect(() => { if (!quoteIsCurrent && quote !== undefined) setStatus("Quote expired — request a new quote"); }, [quoteIsCurrent, quote]); + useEffect(() => { if (approvalReceipt.isSuccess) { setStatus("Approval confirmed"); void refetchAllowance(); } }, [approvalReceipt.isSuccess, refetchAllowance]); + useEffect(() => { if (approvalReceipt.isError) setStatus("Approval reverted"); }, [approvalReceipt.isError]); + useEffect(() => { if (swapReceipt.isSuccess) { setStatus("Swap confirmed"); void refetchBalance(); void refetchOutputBalance(); void refetchAllowance(); } }, [swapReceipt.isSuccess, refetchBalance, refetchOutputBalance, refetchAllowance]); + useEffect(() => { if (swapReceipt.isError) setStatus("Swap reverted"); }, [swapReceipt.isError]); + + async function fetchQuote() { + if (!amountIn || !contractReady || wrongNetwork) return; + setStatus("Fetching quote"); + try { + const result = await quoteRead.refetch(); + const value = result.data?.[1]; + if (!value) throw new Error(); + setQuote(value); setQuotedKey(quoteIdentityKey(identity)); setStatus("Review swap"); + } catch { setQuote(undefined); setQuotedKey(undefined); setStatus("Quote unavailable — check the local RPC and route"); } + } + function approve() { + if (!amountIn || busy) return; + setStatus("Waiting for approval signature"); + approval.writeContract({ abi: erc20Abi, address: input.address, functionName: "approve", args: [config.contractAddress, amountIn], chainId: config.chainId }, { onSuccess: () => setStatus("Approval pending"), onError: (error) => setStatus(error.message.includes("rejected") ? "Transaction rejected" : "Approval failed") }); + } + function executeSwap() { + if (!address || !amountIn || !minimum || busy || !quoteIsCurrent) return; + setStatus("Waiting for swap signature"); + swap.writeContract({ abi: swapGuardAbi, address: config.contractAddress, functionName: "swapExactInput", args: [{ amountIn, amountOutMin: minimum, path, recipient: address, deadline: createDeadline(deadlineMinutes) }], chainId: config.chainId }, { onSuccess: () => setStatus("Swap pending"), onError: (error) => setStatus(error.message.includes("rejected") ? "Transaction rejected" : "Swap failed") }); + } + async function copyHash() { if (!transactionHash) return; await navigator.clipboard.writeText(transactionHash); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } + const reverse = () => { setInputIndex(1 - inputIndex); setAmountText(""); setQuote(undefined); setQuotedKey(undefined); setStatus("Enter an amount"); }; + + return
+
Live local flow{isConnected ? : connectors.length ? : Install an injected wallet to continue.}
+ {(connectionError || switchError) &&

{connectionError ? "Wallet connection was rejected or failed." : "Network switch was rejected or failed."}

} + {wrongNetwork &&
Connected to chain {chainId}; expected {config.chainId}.
} +
{ setAmountText(event.target.value); setStatus("Enter an amount"); }} />
+ +
{currentQuote === undefined ? "—" : formatTokenAmount(currentQuote, output.decimals)}{output.symbol}
+
{slippage === "custom" && }
+ {currentQuote !== undefined &&
Route
{formatRoute([input, output])}
Minimum received
{minimum === undefined ? "Invalid slippage" : `${formatTokenAmount(minimum, output.decimals)} ${output.symbol}`}
Selected slippage
{selectedSlippage}%
} + {!isConnected ? : currentQuote === undefined ? : needsApproval ? : } +

{status}

{transactionHash &&
{shortenAddress(transactionHash)}{transactionUrl ? View transaction ↗ : }
} +
+
; +} diff --git a/web/src/components/PortfolioPage.tsx b/web/src/components/PortfolioPage.tsx new file mode 100644 index 0000000..f377589 --- /dev/null +++ b/web/src/components/PortfolioPage.tsx @@ -0,0 +1,27 @@ +import type { AppConfig } from "../config/env"; +import { AppHeader } from "./AppHeader"; +import { HeroSection } from "./HeroSection"; +import { PortfolioSwapPreview } from "./PortfolioSwapPreview"; +import { ProjectHighlights } from "./ProjectHighlights"; +import { ArchitectureSection } from "./ArchitectureSection"; +import { TestingSection } from "./TestingSection"; +import { SecuritySection } from "./SecuritySection"; +import { Footer } from "./Footer"; + +export function PortfolioPage({ config }: { config: AppConfig }) { + return
+ +
+ +
+
Interactive swap flow preview

Explore the safeguards before touching a wallet.

Adjust an illustrative USDT–DAI route to see how exact input, slippage, minimum received, and deadlines fit together. This preview never connects to a chain or produces a transaction.

  • Demo data is labeled at every step
  • No wallet or real funds required
  • Live execution stays in the reproducible local fork
+ +
+ + + + +
+
+
; +} diff --git a/web/src/components/PortfolioSwapPreview.tsx b/web/src/components/PortfolioSwapPreview.tsx new file mode 100644 index 0000000..b9960ad --- /dev/null +++ b/web/src/components/PortfolioSwapPreview.tsx @@ -0,0 +1,33 @@ +import { useMemo, useState } from "react"; +import { formatUnits } from "viem"; +import { calculateDemoMinimum, calculateDemoQuote } from "../lib/preview"; +import { slippageToBps } from "../lib/slippage"; + +export function PortfolioSwapPreview() { + const [input, setInput] = useState<"USDT" | "DAI">("USDT"); + const [amount, setAmount] = useState("100"); + const [slippage, setSlippage] = useState("1"); + const [deadline, setDeadline] = useState(15); + const output = input === "USDT" ? "DAI" : "USDT"; + const outputDecimals = output === "USDT" ? 6 : 18; + const preview = useMemo(() => { + try { + const quote = calculateDemoQuote(amount, input); + const bps = slippageToBps(slippage); + return { quote, minimum: calculateDemoMinimum(quote, bps) }; + } catch { return undefined; } + }, [amount, input, slippage]); + const display = (value: bigint) => Number(formatUnits(value, outputDecimals)).toLocaleString(undefined, { maximumFractionDigits: 4 }); + const reverse = () => setInput(input === "USDT" ? "DAI" : "USDT"); + + return
+
Demo data
Not an on-chain quotation
+
setAmount(event.target.value)} aria-describedby="demo-note" />
+ +
{preview ? display(preview.quote) : "—"}{output}
+
+
Route
{input} → {output}
Minimum received
{preview ? `${display(preview.minimum)} ${output}` : "—"}
Price source
Fixed demo assumption
Execution deadline
{deadline} minutes
+ Run the live local fork demo ↗ +

This preview performs no RPC calls, wallet signatures, approvals, or transactions.

+
; +} diff --git a/web/src/components/ProjectHighlights.tsx b/web/src/components/ProjectHighlights.tsx new file mode 100644 index 0000000..3438f2d --- /dev/null +++ b/web/src/components/ProjectHighlights.tsx @@ -0,0 +1,7 @@ +const highlights = [ + ["01", "Router integration", "Minimal Uniswap V2-compatible interface with direct and multi-hop route support."], + ["02", "Allowance discipline", "The user approves an exact input; SwapGuard grants the router only a temporary exact allowance."], + ["03", "Execution protection", "Bounded slippage, nonzero minimum output, explicit recipient, and a transaction deadline."], + ["04", "Reproducible evidence", "Behavioral mocks, fuzz invariants, and a real router suite pinned to an Arbitrum block."], +]; +export function ProjectHighlights() { return
What SwapGuard demonstrates

A small contract with a complete integration story.

{highlights.map(([number, title, copy]) =>
{number}

{title}

{copy}

)}
; } diff --git a/web/src/components/SecuritySection.tsx b/web/src/components/SecuritySection.tsx new file mode 100644 index 0000000..86cd306 --- /dev/null +++ b/web/src/components/SecuritySection.tsx @@ -0,0 +1 @@ +export function SecuritySection() { return
Security model

Guardrails, not guarantees.

SwapGuard limits common integration risk while keeping its trust assumptions explicit.

Review the security model ↗
  • Exact temporary allowancesNo unlimited router approval.
  • Direct output deliveryThe router sends output to the recipient.
  • Slippage and deadline enforcementExecution must satisfy the submitted bounds.
  • Honest trust boundaryQuotes are not oracles; MEV and malicious tokens remain risks.
; } diff --git a/web/src/components/TestingSection.tsx b/web/src/components/TestingSection.tsx new file mode 100644 index 0000000..c8b7222 --- /dev/null +++ b/web/src/components/TestingSection.tsx @@ -0,0 +1,2 @@ +const tests = [["25", "Unit tests", "Validation, swaps, failures, events"], ["2 × 256", "Fuzz runs", "Slippage and balance invariants"], ["5", "Fork cases", "Pinned real-router scenarios"], ["21", "Frontend tests", "Modes, config, bigint amounts, previews"]]; +export function TestingSection() { return
Testing evidence

Isolation for edge cases. Forks for reality.

Counts reflect the repository’s executed local suite. Fork cases require an archive RPC and are skipped cleanly when it is unavailable.

{tests.map(([count, title, copy]) =>
{count}

{title}

{copy}

)}
; } diff --git a/web/src/config/chains.ts b/web/src/config/chains.ts index d9c9ef9..3f8fa4c 100644 --- a/web/src/config/chains.ts +++ b/web/src/config/chains.ts @@ -1,8 +1,10 @@ import { defineChain } from "viem"; +import type { LiveConfig } from "./env"; -export const localAnvil = defineChain({ - id: Number(import.meta.env.VITE_CHAIN_ID || 31337), - name: "Arbitrum Anvil Fork", +export const createAppChain = (config: LiveConfig) => defineChain({ + id: config.chainId, + name: config.mode === "local" ? "Arbitrum Anvil Fork" : "SwapGuard Testnet", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, - rpcUrls: { default: { http: [import.meta.env.VITE_RPC_URL || "http://127.0.0.1:8545"] } }, + rpcUrls: { default: { http: [config.rpcUrl] } }, + blockExplorers: config.explorerUrl ? { default: { name: "Block explorer", url: config.explorerUrl } } : undefined, }); diff --git a/web/src/config/contracts.ts b/web/src/config/contracts.ts index bc336ac..114fa32 100644 --- a/web/src/config/contracts.ts +++ b/web/src/config/contracts.ts @@ -1,7 +1,3 @@ -import type { Address } from "viem"; - -export const swapGuardAddress = (import.meta.env.VITE_SWAP_GUARD_ADDRESS || "0x0000000000000000000000000000000000000000") as Address; - export const swapGuardAbi = [ { type: "function", name: "quoteExactInput", stateMutability: "view", inputs: [{ name: "amountIn", type: "uint256" }, { name: "path", type: "address[]" }], outputs: [{ name: "amounts", type: "uint256[]" }, { name: "estimatedAmountOut", type: "uint256" }] }, { type: "function", name: "calculateMinAmountOut", stateMutability: "pure", inputs: [{ name: "quotedAmountOut", type: "uint256" }, { name: "slippageBps", type: "uint256" }], outputs: [{ type: "uint256" }] }, diff --git a/web/src/config/env.test.ts b/web/src/config/env.test.ts new file mode 100644 index 0000000..3055920 --- /dev/null +++ b/web/src/config/env.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseAppConfig, parseAppMode } from "./env"; + +const validLocal = { + VITE_APP_MODE: "local", + VITE_CHAIN_ID: "31337", + VITE_RPC_URL: "http://127.0.0.1:8545", + VITE_SWAP_GUARD_ADDRESS: "0x1111111111111111111111111111111111111111", + VITE_TOKEN_IN_ADDRESS: "0x2222222222222222222222222222222222222222", + VITE_TOKEN_OUT_ADDRESS: "0x3333333333333333333333333333333333333333", +}; + +describe("application configuration", () => { + it("defaults unknown and missing modes to portfolio", () => { + expect(parseAppMode()).toBe("portfolio"); + expect(parseAppMode("unexpected")).toBe("portfolio"); + }); + it("keeps portfolio mode blockchain-disabled without live variables", () => { + expect(parseAppConfig({})).toMatchObject({ mode: "portfolio", liveEnabled: false, errors: [] }); + }); + it("activates local mode only with complete valid configuration", () => { + expect(parseAppConfig(validLocal)).toMatchObject({ mode: "local", liveEnabled: true, chainId: 31337 }); + }); + it("detects a zero contract address", () => { + const config = parseAppConfig({ ...validLocal, VITE_SWAP_GUARD_ADDRESS: "0x0000000000000000000000000000000000000000" }); + expect(config.liveEnabled).toBe(false); + expect(config.errors.join(" ")).toContain("nonzero Ethereum address"); + }); + it("rejects invalid chain IDs", () => { + const config = parseAppConfig({ ...validLocal, VITE_CHAIN_ID: "not-a-chain" }); + expect(config.liveEnabled).toBe(false); + expect(config.errors).toContain("Chain ID must be a positive integer."); + }); + it("keeps incomplete local mode disabled with useful errors", () => { + const config = parseAppConfig({ VITE_APP_MODE: "local" }); + expect(config.liveEnabled).toBe(false); + expect(config.errors.length).toBeGreaterThan(3); + }); +}); diff --git a/web/src/config/env.ts b/web/src/config/env.ts new file mode 100644 index 0000000..387c82e --- /dev/null +++ b/web/src/config/env.ts @@ -0,0 +1,85 @@ +import { getAddress, isAddress, zeroAddress, type Address } from "viem"; + +export type AppMode = "portfolio" | "local" | "testnet"; +type PublicEnv = Record; + +export type PortfolioConfig = { + mode: "portfolio"; + liveEnabled: false; + githubUrl: string; + errors: string[]; +}; + +export type LiveConfig = { + mode: "local" | "testnet"; + liveEnabled: true; + githubUrl: string; + errors: string[]; + chainId: number; + rpcUrl: string; + contractAddress: Address; + tokenInAddress: Address; + tokenOutAddress: Address; + explorerUrl?: string; +}; + +export type DisabledLiveConfig = { + mode: "local" | "testnet"; + liveEnabled: false; + githubUrl: string; + errors: string[]; +}; + +export type AppConfig = PortfolioConfig | LiveConfig | DisabledLiveConfig; + +const DEFAULT_GITHUB_URL = "https://github.com/alsaecas/swapguard"; + +export function parseAppMode(value?: string): AppMode { + if (!value || value === "portfolio") return "portfolio"; + if (value === "local" || value === "testnet") return value; + return "portfolio"; +} + +function parseAddress(value: string | undefined, label: string, errors: string[]): Address | undefined { + if (!value || !isAddress(value) || value.toLowerCase() === zeroAddress) { + errors.push(`${label} must be a nonzero Ethereum address.`); + return undefined; + } + return getAddress(value); +} + +function validUrl(value: string | undefined, label: string, errors: string[], required: boolean) { + if (!value && !required) return undefined; + try { + const url = new URL(value || ""); + if (!/^https?:$/.test(url.protocol)) throw new Error(); + return url.toString().replace(/\/$/, ""); + } catch { + errors.push(`${label} must be a valid HTTP URL.`); + return undefined; + } +} + +export function parseAppConfig(env: PublicEnv): AppConfig { + const mode = parseAppMode(env.VITE_APP_MODE); + const githubUrl = validUrl(env.VITE_GITHUB_URL || DEFAULT_GITHUB_URL, "GitHub URL", [], true) || DEFAULT_GITHUB_URL; + if (mode === "portfolio") return { mode, liveEnabled: false, githubUrl, errors: [] }; + + const errors: string[] = []; + const chainId = Number(env.VITE_CHAIN_ID); + if (!Number.isSafeInteger(chainId) || chainId <= 0) errors.push("Chain ID must be a positive integer."); + const rpcUrl = validUrl(env.VITE_RPC_URL, "RPC URL", errors, true); + const contractAddress = parseAddress(env.VITE_SWAP_GUARD_ADDRESS, "SwapGuard address", errors); + const tokenInAddress = parseAddress(env.VITE_TOKEN_IN_ADDRESS, "Input token address", errors); + const tokenOutAddress = parseAddress(env.VITE_TOKEN_OUT_ADDRESS, "Output token address", errors); + if (tokenInAddress && tokenOutAddress && tokenInAddress === tokenOutAddress) { + errors.push("Input and output token addresses must differ."); + } + const explorerUrl = validUrl(env.VITE_BLOCK_EXPLORER_URL, "Block explorer URL", errors, false); + if (errors.length || !rpcUrl || !contractAddress || !tokenInAddress || !tokenOutAddress) { + return { mode, liveEnabled: false, githubUrl, errors }; + } + return { mode, liveEnabled: true, githubUrl, errors, chainId, rpcUrl, contractAddress, tokenInAddress, tokenOutAddress, explorerUrl }; +} + +export const appConfig = parseAppConfig(import.meta.env); diff --git a/web/src/config/tokens.ts b/web/src/config/tokens.ts index 686bd0d..2b57e81 100644 --- a/web/src/config/tokens.ts +++ b/web/src/config/tokens.ts @@ -1,7 +1,13 @@ import type { Address } from "viem"; +import type { LiveConfig } from "./env"; export type Token = { address: Address; symbol: string; name: string; decimals: number }; -export const tokens: Token[] = [ - { address: (import.meta.env.VITE_TOKEN_IN_ADDRESS || "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9") as Address, symbol: "USDT", name: "Tether USD", decimals: 6 }, - { address: (import.meta.env.VITE_TOKEN_OUT_ADDRESS || "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1") as Address, symbol: "DAI", name: "Dai Stablecoin", decimals: 18 }, +export const createTokens = (config: LiveConfig): Token[] => [ + { address: config.tokenInAddress, symbol: "USDT", name: "Tether USD", decimals: 6 }, + { address: config.tokenOutAddress, symbol: "DAI", name: "Dai Stablecoin", decimals: 18 }, ]; + +export const demoTokens = [ + { symbol: "USDT", name: "Tether USD", decimals: 6 }, + { symbol: "DAI", name: "Dai Stablecoin", decimals: 18 }, +] as const; diff --git a/web/src/lib/explorer.ts b/web/src/lib/explorer.ts new file mode 100644 index 0000000..9ca2a6f --- /dev/null +++ b/web/src/lib/explorer.ts @@ -0,0 +1,5 @@ +import type { Hash } from "viem"; + +export function transactionExplorerUrl(baseUrl: string | undefined, hash: Hash): string | undefined { + return baseUrl ? `${baseUrl.replace(/\/$/, "")}/tx/${hash}` : undefined; +} diff --git a/web/src/lib/portfolio.test.ts b/web/src/lib/portfolio.test.ts new file mode 100644 index 0000000..7f9c51f --- /dev/null +++ b/web/src/lib/portfolio.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { formatUnits } from "viem"; +import { transactionExplorerUrl } from "./explorer"; +import { calculateDemoMinimum, calculateDemoQuote } from "./preview"; +import { isQuoteCurrent, quoteIdentityKey } from "./quoteState"; + +describe("portfolio preview", () => { + it("calculates the labeled fixed-rate example quote", () => { + expect(formatUnits(calculateDemoQuote("100", "USDT"), 18)).toBe("99.92"); + }); + it("applies preview slippage with integer arithmetic", () => { + expect(calculateDemoMinimum(100_000n, 100n)).toBe(99_000n); + }); +}); + +describe("transaction presentation", () => { + const hash = `0x${"ab".repeat(32)}` as const; + it("builds a real explorer URL", () => expect(transactionExplorerUrl("https://explorer.test/", hash)).toBe(`https://explorer.test/tx/${hash}`)); + it("does not create a fake link without an explorer", () => expect(transactionExplorerUrl(undefined, hash)).toBeUndefined()); +}); + +describe("quote invalidation", () => { + const identity = { amount: "10", inputIndex: 0, slippage: "1", chainId: 31337, account: "0x1", contract: "0x2" }; + it("keeps a quote only for the same inputs", () => expect(isQuoteCurrent(quoteIdentityKey(identity), identity)).toBe(true)); + it("invalidates a quote when an identity field changes", () => expect(isQuoteCurrent(quoteIdentityKey(identity), { ...identity, slippage: "2" })).toBe(false)); +}); diff --git a/web/src/lib/preview.ts b/web/src/lib/preview.ts new file mode 100644 index 0000000..20c0b9a --- /dev/null +++ b/web/src/lib/preview.ts @@ -0,0 +1,14 @@ +import { parseUnits } from "viem"; +import { minimumOutput } from "./slippage"; + +const DEMO_RATE_SCALE = 1_000_000n; +const USDT_TO_DAI_RATE = 999_200n; + +export function calculateDemoQuote(amount: string, inputSymbol: "USDT" | "DAI"): bigint { + const decimals = inputSymbol === "USDT" ? 6 : 18; + const parsed = parseUnits(amount || "0", decimals); + if (inputSymbol === "USDT") return (parsed * USDT_TO_DAI_RATE * 10n ** 12n) / DEMO_RATE_SCALE; + return (parsed * DEMO_RATE_SCALE) / (USDT_TO_DAI_RATE * 10n ** 12n); +} + +export const calculateDemoMinimum = (quote: bigint, slippageBps: bigint) => minimumOutput(quote, slippageBps); diff --git a/web/src/lib/quoteState.ts b/web/src/lib/quoteState.ts new file mode 100644 index 0000000..bb9fdc9 --- /dev/null +++ b/web/src/lib/quoteState.ts @@ -0,0 +1,11 @@ +export type QuoteIdentity = { + amount: string; + inputIndex: number; + slippage: string; + chainId?: number; + account?: string; + contract?: string; +}; + +export const quoteIdentityKey = (identity: QuoteIdentity) => JSON.stringify(identity); +export const isQuoteCurrent = (quotedKey: string | undefined, current: QuoteIdentity) => quotedKey === quoteIdentityKey(current); diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts index 85e37e0..efefce1 100644 --- a/web/src/lib/utils.test.ts +++ b/web/src/lib/utils.test.ts @@ -3,7 +3,7 @@ import { parseTokenAmount, formatTokenAmount } from "./amounts"; import { createDeadline } from "./deadline"; import { formatRoute, shortenAddress } from "./formatting"; import { minimumOutput, slippageToBps } from "./slippage"; -import { tokens } from "../config/tokens"; +import type { Token } from "../config/tokens"; describe("amount utilities", () => { it("parses token decimals without floating point", () => expect(parseTokenAmount("1.25", 6)).toBe(1_250_000n)); @@ -16,5 +16,11 @@ describe("swap previews", () => { it("rejects invalid custom slippage", () => expect(() => slippageToBps("10.01")).toThrow()); it("creates a deadline", () => expect(createDeadline(15, 1_000)).toBe(1_900n)); it("shortens an address", () => expect(shortenAddress("0x1234567890123456789012345678901234567890")).toBe("0x1234…7890")); - it("formats a route", () => expect(formatRoute(tokens)).toBe("USDT → DAI")); + it("formats a route", () => { + const route: Token[] = [ + { address: "0x1111111111111111111111111111111111111111", symbol: "USDT", name: "Tether", decimals: 6 }, + { address: "0x2222222222222222222222222222222222222222", symbol: "DAI", name: "Dai", decimals: 18 }, + ]; + expect(formatRoute(route)).toBe("USDT → DAI"); + }); }); diff --git a/web/src/main.tsx b/web/src/main.tsx index a45f462..b1fd81f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,12 +3,18 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider, createConfig, http } from "wagmi"; import { injected } from "wagmi/connectors"; -import { localAnvil } from "./config/chains"; +import { createAppChain } from "./config/chains"; +import { appConfig, type LiveConfig } from "./config/env"; import App from "./App"; import "./styles.css"; -const config = createConfig({ chains: [localAnvil], connectors: [injected()], transports: { [localAnvil.id]: http() } }); const queryClient = new QueryClient(); -createRoot(document.getElementById("root")!).render( - , -); +const root = createRoot(document.getElementById("root")!); +if (appConfig.liveEnabled) { + const liveConfig = appConfig as LiveConfig; + const chain = createAppChain(liveConfig); + const config = createConfig({ chains: [chain], connectors: [injected()], transports: { [chain.id]: http(liveConfig.rpcUrl) } }); + root.render(); +} else { + root.render(); +} diff --git a/web/src/styles.css b/web/src/styles.css index cdbe6aa..7a38320 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1 +1,85 @@ -:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #eef2ff; background: #080b14; font-synthesis: none; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 50% -10%, #263b75 0, #10162a 32%, #080b14 62%); } button, input, select { font: inherit; } button { border: 0; border-radius: 14px; padding: 14px 20px; font-weight: 700; color: #071022; background: #81a9ff; cursor: pointer; } button:disabled { opacity: .45; cursor: not-allowed; } main { width: min(680px, calc(100% - 32px)); margin: auto; padding: 28px 0 60px; } header { display: flex; justify-content: space-between; align-items: center; } h1 { font-size: 32px; margin: 2px 0; } .eyebrow { color: #8aa7e8; font-size: 11px; font-weight: 800; letter-spacing: .16em; } .ghost { background: #1a233b; color: #dae4ff; } .hero { margin: 45px 0 26px; max-width: 600px; font-size: clamp(24px, 5vw, 40px); line-height: 1.12; font-weight: 650; } .warning { background: #4b3515; border: 1px solid #946c25; padding: 14px; border-radius: 12px; margin-bottom: 16px; } .card { background: rgba(16, 22, 40, .92); border: 1px solid #293556; padding: 18px; border-radius: 24px; box-shadow: 0 20px 70px #0007; } .token-row { background: #0b1020; border: 1px solid #202b49; padding: 16px; border-radius: 18px; } .token-row label { display: flex; justify-content: space-between; color: #9eabd0; font-size: 13px; } .token-row div { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; font-size: 28px; } .token-row input { width: 70%; background: transparent; color: white; border: 0; outline: 0; font-size: 28px; } .token-row strong { font-size: 17px; background: #202b49; border-radius: 20px; padding: 9px 12px; } .reverse { display: block; margin: -5px auto; position: relative; z-index: 1; padding: 8px 12px; background: #2c3c66; color: white; border: 4px solid #101628; } .settings { display: flex; gap: 12px; margin: 16px 0; } .settings label { flex: 1; color: #9eabd0; font-size: 13px; } select, .settings input { margin-left: 6px; background: #131c32; color: white; border: 1px solid #33415f; padding: 8px; border-radius: 8px; max-width: 100px; } dl { background: #0c1222; padding: 14px; border-radius: 14px; } dl div { display: flex; justify-content: space-between; margin: 8px 0; } dt { color: #8d9aba; } dd { margin: 0; } .card > button:not(.reverse) { width: 100%; font-size: 16px; } .status { text-align: center; color: #aebde5; } a { color: #91b3ff; } .education { color: #9eabd0; line-height: 1.6; padding: 18px; } .education h2 { color: #e8edff; font-size: 18px; } @media (max-width: 520px) { .settings { flex-direction: column; } .hero { margin-top: 34px; } } +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap'); + +:root { font-family: Manrope, ui-sans-serif, system-ui, sans-serif; color: #f4f7ff; background: #070a10; font-synthesis: none; --ink: #f4f7ff; --muted: #9ba6b9; --line: #242b38; --panel: #10151e; --panel-soft: #0c1119; --blue: #6d91ff; --cyan: #73e0d1; --lime: #c9ff72; } +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 80% -10%, #172544 0, transparent 30%), radial-gradient(circle at 0 35%, #10241f 0, transparent 24%), #070a10; } +body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .16; background-image: linear-gradient(#ffffff08 1px, transparent 1px), linear-gradient(90deg, #ffffff08 1px, transparent 1px); background-size: 42px 42px; mask-image: linear-gradient(to bottom, black, transparent 75%); } +button, input, select { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #9bb3ff; outline-offset: 3px; } +.site-shell { width: min(1180px, calc(100% - 40px)); margin: auto; position: relative; } +.site-header { min-height: 84px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 800; text-decoration: none; letter-spacing: -.03em; } +.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; background: linear-gradient(135deg, var(--lime), var(--cyan)); color: #07100c; border-radius: 9px; font-size: 17px; box-shadow: 0 0 28px #73e0d133; } +.header-actions, .hero-actions, .trust-row { display: flex; align-items: center; gap: 12px; } +.mode-badge { padding: 7px 11px; border-radius: 999px; font: 500 11px/1 DM Mono, monospace; letter-spacing: .08em; text-transform: uppercase; border: 1px solid #354052; color: #b7c3d7; background: #121822; } +.mode-badge.portfolio { color: #d9ff9c; border-color: #769b3b; background: #192212; } +.mode-badge.local { color: #b9f8ef; border-color: #31796f; background: #10251f; } +.button { display: inline-flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: 11px; padding: 12px 16px; font-weight: 700; font-size: 14px; text-decoration: none; cursor: pointer; transition: transform .18s ease, background .18s ease, border-color .18s ease; } +.button:hover:not(:disabled) { transform: translateY(-2px); } +.button:disabled { opacity: .45; cursor: not-allowed; } +.button-primary { background: var(--ink); color: #0a0d13; } +.button-secondary { border-color: #394354; background: #151b25; color: var(--ink); } +.button-quiet { background: transparent; border-color: #303846; color: #c5cfdd; } +.hero-section { min-height: 690px; display: grid; grid-template-columns: 1.1fr .9fr; gap: 70px; align-items: center; padding: 90px 0 80px; } +.eyebrow, .section-kicker { display: inline-flex; align-items: center; gap: 9px; color: var(--cyan); font: 500 12px/1.3 DM Mono, monospace; letter-spacing: .1em; text-transform: uppercase; } +.pulse { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 5px #c9ff721a, 0 0 20px #c9ff72; } +h1, h2, h3, p { margin-top: 0; } +.hero-copy h1 { max-width: 760px; margin: 24px 0 26px; font-size: clamp(52px, 6vw, 84px); line-height: .98; letter-spacing: -.065em; } +.hero-copy h1 em { color: var(--lime); font-style: normal; } +.hero-copy > p { max-width: 650px; color: #aeb8c9; font-size: 18px; line-height: 1.7; } +.hero-actions { margin-top: 34px; } +.trust-row { gap: 24px; margin-top: 34px; color: #8490a3; font: 500 11px DM Mono, monospace; text-transform: uppercase; } +.trust-row span { display: flex; align-items: center; gap: 7px; } +.trust-row span::before { content: "✓"; color: var(--cyan); } +.hero-visual { position: relative; aspect-ratio: 1; max-width: 480px; width: 100%; justify-self: end; display: grid; place-items: center; } +.hero-visual::before { content: ""; position: absolute; inset: 12%; background: radial-gradient(circle, #597cff29, transparent 62%); filter: blur(18px); } +.orbit { position: absolute; border: 1px solid #53617b77; border-radius: 50%; } +.orbit-one { inset: 15%; animation: spin 28s linear infinite; } +.orbit-one::after { content: ""; position: absolute; top: 17%; left: 2%; width: 10px; height: 10px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 18px var(--cyan); } +.orbit-two { inset: 2%; border-style: dashed; opacity: .35; animation: spin 42s linear infinite reverse; } +.guard-core { width: 170px; height: 170px; border: 1px solid #7e9cff88; border-radius: 42px; display: grid; place-content: center; text-align: center; background: linear-gradient(145deg, #1d2940, #0b1019); box-shadow: 0 28px 80px #0009, inset 0 1px #ffffff1f; transform: rotate(-6deg); } +.guard-core span { font-size: 48px; font-weight: 800; letter-spacing: -.08em; color: var(--lime); } +.guard-core small { font: 500 10px DM Mono, monospace; color: #8f9bb0; letter-spacing: .16em; } +.floating-chip { position: absolute; border: 1px solid #374256; background: #101720e8; box-shadow: 0 12px 32px #0007; border-radius: 10px; padding: 10px 13px; font: 500 11px DM Mono, monospace; color: #c5d0e0; } +.chip-wallet { top: 17%; left: 3%; }.chip-router { right: -1%; top: 32%; }.chip-min { bottom: 17%; left: 6%; color: var(--lime); } +.preview-layout { display: grid; grid-template-columns: .85fr 1.15fr; gap: 76px; align-items: center; padding: 120px 0; border-top: 1px solid var(--line); } +.section-copy h2, .section-heading h2, .security h2, .configuration-card h1 { margin: 18px 0 20px; font-size: clamp(34px, 4.4vw, 58px); line-height: 1.08; letter-spacing: -.045em; } +.section-copy p, .section-heading > p, .security > div > p, .configuration-card > p { color: var(--muted); line-height: 1.75; font-size: 16px; } +.check-list { padding: 0; list-style: none; margin: 30px 0 0; display: grid; gap: 13px; color: #c9d2df; } +.check-list li::before { content: "✓"; color: var(--lime); margin-right: 10px; } +.swap-card { width: min(100%, 560px); justify-self: end; border: 1px solid #303a4c; border-radius: 26px; padding: 20px; background: linear-gradient(160deg, #151c28, #0b1018 70%); box-shadow: 0 34px 90px #0009, inset 0 1px #ffffff12; } +.card-heading { display: flex; justify-content: space-between; padding: 2px 3px 17px; color: #7f8a9d; font: 500 10px DM Mono, monospace; text-transform: uppercase; letter-spacing: .06em; } +.card-heading div { color: var(--lime); }.demo-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--lime); margin-right: 6px; } +.token-panel { background: #090e15; border: 1px solid #242d3b; padding: 16px; border-radius: 17px; } +.token-panel label { display: flex; justify-content: space-between; color: #7f899a; font-size: 12px; } +.token-panel > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; } +.token-panel input, .token-panel output { min-width: 0; width: 70%; border: 0; background: transparent; color: var(--ink); outline: none; font-size: clamp(28px, 4vw, 39px); font-weight: 600; letter-spacing: -.04em; } +.token-pill { flex: none; border: 1px solid #3a4559; background: #192231; color: #f1f5ff; padding: 9px 12px; border-radius: 999px; font-weight: 800; cursor: pointer; } +.token-pill span { color: #8491a5; margin-left: 5px; }.token-pill.static { cursor: default; } +.reverse-button { position: relative; z-index: 1; display: block; width: 36px; height: 36px; margin: -8px auto; border: 4px solid #101722; background: #263247; color: var(--cyan); border-radius: 11px; cursor: pointer; } +.preview-controls { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 17px 0; } +.preview-controls label { color: #8490a3; font-size: 11px; } +.preview-controls select, .preview-controls input { width: 100%; margin-top: 7px; border: 1px solid #303a4a; background: #111823; color: #e4eafa; border-radius: 9px; padding: 9px; } +.quote-details { margin: 0 0 17px; padding: 15px; border: 1px solid #263042; background: #0b111a; border-radius: 13px; } +.quote-details div { display: flex; justify-content: space-between; gap: 20px; margin: 9px 0; font-size: 12px; }.quote-details dt { color: #7e899b; }.quote-details dd { margin: 0; text-align: right; color: #dce4f1; } +.button-preview { width: 100%; background: linear-gradient(90deg, var(--lime), var(--cyan)); color: #07110e; } +.card-disclaimer { margin: 12px 0 0; text-align: center; color: #687386; font-size: 10px; line-height: 1.5; } +.content-section { padding: 115px 0; border-top: 1px solid var(--line); } +.section-heading { max-width: 750px; margin-bottom: 50px; }.section-heading h2 { max-width: 700px; } +.highlight-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 18px; overflow: hidden; } +.highlight-grid article { min-height: 260px; padding: 26px; background: #0d121a; }.highlight-grid article > span { font: 500 11px DM Mono, monospace; color: var(--cyan); }.highlight-grid h3 { margin: 72px 0 12px; font-size: 18px; }.highlight-grid p, .metric-grid p { color: #8f9bad; font-size: 13px; line-height: 1.65; } +.flow-diagram { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid #2a3444; border-radius: 18px; overflow: hidden; background: #0c1118; } +.flow-step { position: relative; min-height: 180px; padding: 24px; display: flex; flex-direction: column; justify-content: space-between; border-right: 1px solid #273142; }.flow-step:last-child { border: 0; }.flow-number { font: 500 10px DM Mono, monospace; color: var(--cyan); }.flow-step h3 { margin: 0 0 8px; }.flow-step p { color: #8591a3; font-size: 12px; line-height: 1.5; }.flow-arrow { position: absolute; z-index: 1; right: -14px; top: 50%; width: 28px; height: 28px; display: grid; place-items: center; color: var(--lime); background: #131b27; border: 1px solid #354156; border-radius: 50%; } +.text-link { display: inline-block; margin-top: 28px; color: var(--cyan); font-weight: 700; text-decoration: none; } +.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }.metric-grid article { padding: 26px; border: 1px solid #293344; border-radius: 15px; background: linear-gradient(145deg, #111823, #0b0f16); }.metric-grid strong { display: block; margin-bottom: 28px; color: var(--lime); font-size: 34px; letter-spacing: -.05em; }.metric-grid h3 { font-size: 15px; } +.security { display: grid; grid-template-columns: .8fr 1.2fr; gap: 80px; }.security ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid #2a3444; border-radius: 18px; overflow: hidden; }.security li { min-height: 160px; padding: 24px; border-right: 1px solid #2a3444; border-bottom: 1px solid #2a3444; display: flex; flex-direction: column; justify-content: flex-end; }.security li:nth-child(2n) { border-right: 0; }.security li:nth-last-child(-n+2) { border-bottom: 0; }.security li strong { margin-bottom: 8px; }.security li span { color: #8c98a9; font-size: 12px; line-height: 1.5; } +footer { min-height: 150px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: center; gap: 30px; color: #8590a2; font-size: 12px; }footer p { margin: 0; text-align: center; }footer nav { display: flex; justify-content: flex-end; gap: 18px; }footer nav a { text-decoration: none; }footer nav a:hover { color: var(--cyan); } +.configuration-page { min-height: 690px; display: grid; place-items: center; }.configuration-card { max-width: 700px; text-align: center; padding: 50px; border: 1px solid #58452a; background: #17130d; border-radius: 22px; }.configuration-card ul { text-align: left; color: #d4b987; }.configuration-card .hero-actions { justify-content: center; }.warning-icon { display: grid; place-items: center; width: 48px; height: 48px; margin: 0 auto 18px; border-radius: 50%; background: #4c3615; color: #ffd082; font-size: 24px; } +.live-intro { padding: 70px 0 45px; max-width: 760px; }.live-intro h1 { font-size: clamp(46px, 6vw, 72px); letter-spacing: -.06em; margin: 22px 0; }.live-intro p { color: var(--muted); line-height: 1.7; }.live-layout { display: grid; grid-template-columns: .75fr 1.25fr; gap: 40px; padding-bottom: 100px; }.live-status-panel { align-self: start; border: 1px solid #293344; border-radius: 18px; padding: 24px; background: #0d131c; }.live-status-panel h2 { font-size: 16px; }.live-status-panel dl div { display: grid; grid-template-columns: 110px 1fr; gap: 12px; margin: 13px 0; font-size: 12px; }.live-status-panel dt { color: #7f8b9e; }.live-status-panel dd { margin: 0; overflow-wrap: anywhere; }.live-note { color: #748093; font-size: 11px; line-height: 1.6; border-top: 1px solid #293344; padding-top: 18px; margin-top: 20px; }.live-card { justify-self: stretch; width: 100%; }.live-wallet-row, .network-warning, .transaction-hash { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }.network-warning { border: 1px solid #725522; background: #2b2110; border-radius: 11px; padding: 12px; color: #f0ce8a; font-size: 12px; }.notice { color: #9aa7ba; font-size: 12px; }.notice.error { color: #ffb2a8; }.transaction-status { text-align: center; color: #aebbd0; font-size: 12px; }.transaction-hash { margin: 12px 0 0; padding: 11px; background: #0b1119; border-radius: 9px; font: 500 11px DM Mono, monospace; }.transaction-hash a, .transaction-hash button { color: var(--cyan); background: none; border: 0; cursor: pointer; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (max-width: 900px) { .hero-section, .preview-layout, .security, .live-layout { grid-template-columns: 1fr; }.hero-section { padding-top: 70px; }.hero-visual { justify-self: center; max-width: 400px; }.swap-card { justify-self: center; }.highlight-grid, .metric-grid { grid-template-columns: repeat(2, 1fr); }.flow-diagram { grid-template-columns: repeat(2, 1fr); }.flow-step:nth-child(2) { border-right: 0; }.flow-step:nth-child(-n+2) { border-bottom: 1px solid #273142; }.flow-arrow { display: none; }footer { grid-template-columns: 1fr; text-align: center; padding: 35px 0; }footer p { text-align: center; }footer nav { justify-content: center; }.live-status-panel { order: 2; }} +@media (max-width: 600px) { .site-shell { width: min(100% - 24px, 1180px); }.site-header { min-height: 72px; }.desktop-only { display: none; }.header-actions { gap: 7px; }.hero-section { min-height: auto; padding: 64px 0 75px; gap: 35px; }.hero-copy h1 { font-size: 52px; }.hero-copy > p { font-size: 16px; }.hero-actions { align-items: stretch; flex-direction: column; }.trust-row { flex-direction: column; align-items: flex-start; gap: 10px; }.hero-visual { max-width: 310px; }.floating-chip { font-size: 9px; }.preview-layout, .content-section { padding: 80px 0; }.preview-layout { gap: 42px; }.swap-card { padding: 13px; border-radius: 20px; }.card-heading { flex-direction: column; gap: 7px; }.highlight-grid, .metric-grid, .flow-diagram, .security ul { grid-template-columns: 1fr; }.highlight-grid article { min-height: 210px; }.highlight-grid h3 { margin-top: 48px; }.flow-step, .flow-step:nth-child(n) { border-right: 0; border-bottom: 1px solid #273142; }.flow-step:last-child { border-bottom: 0; }.security { gap: 45px; }.security li:nth-child(n) { border-right: 0; border-bottom: 1px solid #2a3444; }.security li:last-child { border-bottom: 0; }.preview-controls { grid-template-columns: 1fr; }.configuration-card { padding: 28px 18px; }.live-wallet-row, .network-warning { align-items: stretch; flex-direction: column; }.live-status-panel dl div { grid-template-columns: 1fr; gap: 3px; }} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } diff --git a/web/vercel.json b/web/vercel.json new file mode 100644 index 0000000..556a306 --- /dev/null +++ b/web/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "dist" +}