Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions VERCEL_IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.vercel
.env*
16 changes: 14 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SwapGuard local Uniswap V2 swap demonstration" />
<title>SwapGuard</title>
<meta name="description" content="SwapGuard is a portfolio-grade Uniswap V2 integration demonstrating secure ERC-20 swap flows, slippage protection, temporary allowances, and reproducible Arbitrum fork testing." />
<meta name="theme-color" content="#070a10" />
<meta property="og:type" content="website" />
<meta property="og:title" content="SwapGuard — Secure swap integration, made inspectable" />
<meta property="og:description" content="Explore ERC-20 approvals, slippage protection, temporary router allowances, and pinned Arbitrum fork testing." />
<meta property="og:url" content="https://swapguard-zeta.vercel.app/" />
<meta property="og:image" content="https://swapguard-zeta.vercel.app/social-card.svg" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="SwapGuard — Secure swap integration, made inspectable" />
<meta name="twitter:description" content="A portfolio-grade Uniswap V2 integration with a safe interactive preview and reproducible local execution." />
<meta name="twitter:image" content="https://swapguard-zeta.vercel.app/social-card.svg" />
<link rel="canonical" href="https://swapguard-zeta.vercel.app/" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>SwapGuard — Secure Uniswap V2 Integration</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
1 change: 1 addition & 0 deletions web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/public/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 8 additions & 82 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<bigint>();
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 <ConfigurationErrorPage config={appConfig} />;
return <PortfolioPage config={appConfig} />;
}
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 <main><header><div><span className="eyebrow">LOCAL FORK LAB</span><h1>SwapGuard</h1></div>{isConnected ? <button className="ghost" onClick={() => disconnect()}>{shortenAddress(address!)}</button> : <button onClick={() => connect({ connector: connectors[0] })}>{connecting ? "Connecting" : "Connect wallet"}</button>}</header>
<section className="hero"><p>Secure exact-input swaps with transparent quotations, bounded slippage, and transaction deadlines.</p></section>
{isConnected && chainId !== localAnvil.id && <aside className="warning">Wrong network. Add the local Anvil RPC at http://127.0.0.1:8545 with chain ID {localAnvil.id}, then switch networks.</aside>}
<section className="card"><div className="token-row"><label>You pay <small>Balance: {balance === undefined ? "—" : formatTokenAmount(balance, input.decimals)}</small></label><div><input aria-label="Input amount" value={amountText} placeholder="0.0" onChange={(event) => { setAmountText(event.target.value); setQuote(undefined); setStatus("Enter an amount"); }} /><select aria-label="Input token" value={inputIndex} onChange={(event) => { setInputIndex(Number(event.target.value)); setQuote(undefined); }}><option value={0}>USDT</option><option value={1}>DAI</option></select></div></div>
<button className="reverse" aria-label="Reverse token pair" onClick={reverse}>⇅</button>
<div className="token-row output"><label>You receive <small>Balance: {outputBalance === undefined ? "—" : formatTokenAmount(outputBalance, output.decimals)}</small></label><div><span>{quote === undefined ? "—" : formatTokenAmount(quote, output.decimals)}</span><select aria-label="Output token" value={1 - inputIndex} onChange={(event) => { setInputIndex(1 - Number(event.target.value)); setQuote(undefined); }}><option value={0}>USDT</option><option value={1}>DAI</option></select></div></div>
<div className="settings"><label>Slippage <select value={slippage} onChange={(e) => setSlippage(e.target.value)}><option value="0.5">0.5%</option><option value="1">1%</option><option value="custom">Custom</option></select>{slippage === "custom" && <input aria-label="Custom slippage percentage" value={customSlippage} onChange={(event) => setCustomSlippage(event.target.value)} />}%</label><label>Deadline <input type="number" min="1" max="120" value={deadlineMinutes} onChange={(e) => setDeadlineMinutes(Number(e.target.value))} /> min</label></div>
{quote !== undefined && <dl><div><dt>Route</dt><dd>{formatRoute([input, output])}</dd></div><div><dt>Minimum received</dt><dd>{minimum === undefined ? "Invalid slippage" : `${formatTokenAmount(minimum, output.decimals)} ${output.symbol}`}</dd></div><div><dt>Selected slippage</dt><dd>{selectedSlippage}%</dd></div><div><dt>Rate</dt><dd>1 {input.symbol} ≈ {amountIn ? formatTokenAmount((quote * 10n ** BigInt(input.decimals)) / amountIn, output.decimals) : "—"} {output.symbol}</dd></div></dl>}
{!isConnected ? <button disabled>Connect wallet</button> : quote === undefined ? <button disabled={!enabled || busy || chainId !== localAnvil.id} onClick={fetchQuote}>Request quote</button> : needsApproval ? <button disabled={busy} onClick={approve}>Approve {input.symbol}</button> : <button disabled={busy || !minimum} onClick={executeSwap}>Execute swap</button>}
<p className="status">{status}</p>{(approval.data || swap.data) && <a href="#transaction">Transaction: {shortenAddress((swap.data || approval.data)!)}</a>}
</section>
<section className="education"><h2>Before you swap</h2><p>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.</p></section>
</main>;
return <LiveApp config={appConfig as LiveConfig} />;
}
8 changes: 8 additions & 0 deletions web/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type Props = { githubUrl: string; mode: "portfolio" | "local" | "testnet"; children?: React.ReactNode };

export function AppHeader({ githubUrl, mode, children }: Props) {
return <header className="site-header">
<a className="brand" href="#top" aria-label="SwapGuard home"><span className="brand-mark">S</span><span>SwapGuard</span></a>
<div className="header-actions"><span className={`mode-badge ${mode}`}>{mode === "portfolio" ? "Portfolio demo" : `${mode} mode`}</span>{children}<a className="button button-quiet desktop-only" href={githubUrl} target="_blank" rel="noreferrer">View source</a></div>
</header>;
}
2 changes: 2 additions & 0 deletions web/src/components/ArchitectureSection.tsx
Original file line number Diff line number Diff line change
@@ -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 <section className="content-section architecture" id="architecture"><div className="section-heading"><span className="section-kicker">Architecture</span><h2>Output goes where it belongs.</h2><p>The adapter coordinates the swap without intentionally retaining user output or granting unlimited router authority.</p></div><div className="flow-diagram">{nodes.map(([title, copy], index) => <div className="flow-step" key={title}><span className="flow-number">0{index + 1}</span><div><h3>{title}</h3><p>{copy}</p></div>{index < nodes.length - 1 && <span className="flow-arrow" aria-hidden="true">→</span>}</div>)}</div><a className="text-link" href="https://github.com/alsaecas/swapguard/blob/main/docs/ARCHITECTURE.md" target="_blank" rel="noreferrer">Explore the complete architecture ↗</a></section>; }
Loading
Loading