The web app. Handles wallet connection, cross-chain swap quotes, and transaction tracking.
| Environment | URL |
|---|---|
| Production | bridgekitty.persistence.one |
| Local dev | http://localhost:5173 |
For the project overview, architecture diagram, and end-to-end flow, see the root README. For the API surface this app talks to, see backend/README.md.
- Tech stack
- Running locally
- Environment variables
- Views
- Project structure
- Quote flow — what happens when the user types an amount
- Transaction execution flow
- Supported chains
- Swap safety
- Wallet connection
- Design system
- Scripts
| Framework | React 18 + Vite |
| Language | TypeScript |
| Styling | Tailwind CSS + custom CSS design system (hf- prefix) |
| Animations | Framer Motion |
| Wallet auth | Privy (optional — falls back to a demo wallet for local dev) |
| Icons | Lucide React |
| State | Local React state. No Redux, Zustand, or persistent store. |
| Routing | None — single-page app with view switching via React state. |
npm install
cp .env.example .env
npm run dev
# → http://localhost:5173Make sure the backend is running on http://localhost:8080 before starting the frontend. See backend/README.md.
# Wallet auth — required for real Privy login. Leave empty to use the demo-wallet fallback.
VITE_PRIVY_APP_ID=
# BridgeKitty backend (the Express API in this repo)
VITE_BRIDGEKITTY_API_BASE_URL=http://localhost:8080/api
# On-chain balance reads — speeds up balance fetching on ETH/Base/Polygon
VITE_ALCHEMY_API_KEY=
# Token USD pricing (CoinGecko primary, CoinMarketCap fallback — both optional)
VITE_COINGECKO_API_KEY=
VITE_CMC_API_KEY=
# Optional — direct /quotes proxy override. Leave blank to use VITE_BRIDGEKITTY_API_BASE_URL.
VITE_BRIDGEKITTY_QUOTE_PROXY_URL=| Variable | Required | Default | What it does |
|---|---|---|---|
VITE_BRIDGEKITTY_API_BASE_URL |
yes | http://localhost:8080/api |
Where the frontend sends quote/wallet/swap/status calls. In production this is https://api.bridgekitty.persistence.one/api. |
VITE_PRIVY_APP_ID |
no | (empty) | Privy app ID for wallet auth. Without it, the app uses a demo-wallet fallback so you can develop without creating a Privy account. Get one from dashboard.privy.io. |
VITE_ALCHEMY_API_KEY |
no | (empty) | Alchemy API key for on-chain balance reads on Ethereum, Base, and Polygon. Without it, the app falls back to public RPCs (slower, sometimes rate-limited). All other chains (BNB Chain, Monad, BOB, Bitlayer, B² Network, Rootstock, Core, Merlin) always use public RPCs. Get one from alchemy.com. |
VITE_COINGECKO_API_KEY |
no | (empty) | CoinGecko demo key for USD price lookups. Without it, anonymous CoinGecko works (more aggressive rate limits). |
VITE_CMC_API_KEY |
no | (empty) | CoinMarketCap key. Used as a fallback if CoinGecko is unavailable. |
VITE_BRIDGEKITTY_QUOTE_PROXY_URL |
no | (empty) | Optional override that bypasses VITE_BRIDGEKITTY_API_BASE_URL for the quotes endpoint specifically. Useful if you want quotes routed through a different proxy than the rest of the API. |
Single-page app. All views are React state, no URL routing.
| View | What it does |
|---|---|
Landing (LandingView.tsx) |
Entry point. Pick Human or Agent mode. |
Swap (SwapView.tsx) |
Cross-chain swap interface. Compares quotes from LI.FI, Squid, deBridge, Relay, Across, Symbiosis, and Meson via a single backend call. Shows fees, ETA, expandable per-route detail (DLN solver fee, min received, rate, ETA, via). Supports MAX, 50%, and "Fit gas" amount shortcuts. |
Agent (AgentView.tsx) |
MCP server docs. Setup guides for Claude Desktop, Claude Code, and any HTTP agent. |
Stats (StatsView.tsx) |
Protocol analytics. Swap volume and unique users over 7/15/30/all-time periods. |
src/
├── components/
│ ├── LandingView.tsx Entry point, mode picker
│ ├── SwapView.tsx Main swap interface
│ ├── AgentView.tsx MCP docs and setup guides
│ ├── StatsView.tsx Protocol analytics dashboard
│ ├── WalletConnector.tsx Privy + demo-wallet fallback
│ ├── TokenSelector.tsx Chain + token picker modal
│ ├── TransactionHistoryModal.tsx Past swaps + bridges modal
│ └── AppProviders.tsx Privy/wagmi/etc. provider tree
│
├── hooks/
│ ├── useSwapQuotes.ts Fetches all-provider quotes via ONE backend call
│ ├── useSwapExecution.ts Calldata sanity check + signing + submission
│ ├── useTokenBalances.ts On-chain token balances (Alchemy + public RPC fallback)
│ ├── useTransactionHistory.ts Past-swap fetcher
│ └── usePrices.ts USD prices (CoinGecko + CMC fallback)
│
├── services/
│ ├── quoteService.ts `getAllSwapQuotes` — single call to /api/quotes
│ ├── transactionStatusService.ts Polls /api/status until completed/failed
│ ├── transactionHistoryService.ts Fetches /api/transactions
│ ├── priceService.ts Token USD price lookups
│ └── balanceService.ts On-chain balance fetching with RPC fallbacks
│
├── lib/
│ ├── chains.ts Supported networks, token registry
│ ├── swap.ts Validation, calldata recipient scan
│ ├── amount.ts Wei ⇆ human-readable conversions
│ ├── erc20.ts ERC-20 contract helpers (allowance, approve)
│ └── apiBaseUrl.ts Resolves VITE_BRIDGEKITTY_API_BASE_URL
│
├── types.ts Shared TypeScript interfaces
├── constants.ts LIVE_PROVIDERS, DEBOUNCE_MS, refresh interval
└── App.tsx Root component and view orchestration
What happens when the user types an amount, picks tokens, or hits MAX:
SwapView.tsx ──── setupAmountDebounce(draft) ────▶ useSwapQuotes
│
│ (1000ms debounce on typing,
│ immediate on MAX/50%/Fit-gas/swap-direction)
▼
fetchQuote(draft)
│
│ abort previous round's AbortController,
│ bump roundRef, set isFetching=true
▼
getAllSwapQuotes(request, signal)
│
│ ONE POST to /api/quotes
▼
┌────────── backend ──────────┐
│ validates input │
│ one upstream /quote call │
│ N parallel /execute calls │
│ sanity filter │
└─────────────┬───────────────┘
│
▼
{ quotes: { lifi, debridge, ... },
failed: { squid: 'no route', ... } }
│
▼
setQuotes(...) — winners only
setIsFetching(false)
setQuoteCountdown(60s)
│
▼
SwapView re-renders — only providers with a
quote appear; sorted lowest-fee first; the
top one is marked "Best".
Round dedup: useSwapQuotes.ts keeps an AbortController ref. When a new round starts, the previous round's controller is .abort()-ed, cancelling the old fetch() at the network layer. Stale results never make it into state.
Auto-refresh: a 60-second countdown starts after every successful round. When it hits zero, useSwapQuotes fires a fresh round automatically — keeping the displayed quotes within their TTL. The countdown pauses while the user is mid-execution (isExecutingRef).
Why one backend call? Persistence's upstream /quote returns every provider's quotes in a single response. We fan out the per-provider /execute calls on the backend and return everything in one payload. 5 providers, 1 frontend HTTP request, 1 upstream /quote, up to 5 upstream /executes. No race conditions, no quoteId collisions, no per-provider HTTP fan-out from the browser.
When the user expands a route card, the values shown are:
| Row | Source field | Notes |
|---|---|---|
| Fee | pQuote.feeUsd |
From the backend → Persistence feeBreakdown.totalFeeUsd. The headline all-in USD cost. |
| DLN solver fee (deBridge only) | pQuote.fixFeeUsd |
From feeBreakdown.fixFeeUsd. Paid in the source chain's native token as extra msg.value. |
| Route spread (deBridge only) | feeUsd − fixFeeUsd |
Computed in JSX. The non-solver portion of the deBridge fee. |
| Min. received | pQuote.destinationAmountMin |
From minOutputAmountRaw, formatted with the destination token's decimals. |
| Rate | destinationAmount / sourceAmount |
Computed client-side. Shown as 1 SRC ≈ X DST. |
| Est. time | pQuote.etaSeconds |
From estimatedTimeSeconds. Shown as ~Xs. |
| Via | pQuote.route |
From Persistence's route string, e.g. "ETH → deBridge DLN → MATIC". |
The summary box below the route list shows:
- The DLN solver fee row (only when
bestQuote.fixFeeUsd > 0). - The BridgeKitty fee row —
formatUsd(integratorFeeUsd)when present and > 0, elseFree ✓. Today this is alwaysFree ✓because Persistence isn't configured to charge an integrator fee for us. When Persistence flips it on, the next quote round will populateintegratorFeeUsdand the row switches to the actual amount automatically — no frontend deploy needed.
When the user clicks Bridge now:
- Calldata sanity check (
useSwapExecution.ts→swap.ts:validateTransactionRequest).- Reject if
tx.tois missing or zero. - Scan the unsigned
tx.datacalldata for the connected wallet's address. If the address isn't present in the bytes that are about to be signed, abort before the wallet popup ever appears.
- Reject if
- ERC-20 approval (for non-native tokens). If the
srcTokenAddressisn't native,ensureTokenApprovalchecks the wallet's allowance for the spender (tx.to) and fires a separateapprove(...)transaction first when needed. Native swaps skip this entirely. eth_sendTransaction. The wallet signs and broadcasts the transaction Persistence built. The frontend never sees the private key.- Record the transaction. After getting back a
txHash, the frontend POSTs/api/swapsand/api/transactionsto save the record on the user's wallet history. - Poll status.
transactionStatusServicecalls/api/status?provider={trackingId}&fromChain={key}&txHash={hash}every few seconds until the upstream returnscompletedorfailed. The UI shows a progress strip with five states:submitted → confirming → bridging → completed(orfailed).
| Chain | Chain ID | Native asset |
|---|---|---|
| Ethereum | 1 | ETH |
| Base | 8453 | ETH |
| BOB | 60808 | ETH |
| BNB Chain | 56 | BNB |
| Monad | 143 | MON |
| Polygon | 137 | POL |
| Bitlayer | 200901 | BTC |
| B² Network | 223 | BTC |
| Rootstock | 30 | RBTC |
| Core | 1116 | CORE |
| Merlin | 4200 | BTC |
Tokens are defined in src/lib/chains.ts. To add a new token, add an entry there and a CoinGecko/CMC slug mapping in priceService.ts.
Four independent guard-rails before the user's wallet is asked to sign — a misrouted quote has to break all four to reach you:
- No wallet → no quote.
useSwapQuotes.tsskips the backend call entirely when no wallet is connected. A zero-address recipient causes some upstream APIs to silently substitute a fallback wallet. - Backend input guard. Backend rejects zero-address
srcWalletAddress, mismatcheddstWalletAddress, or non-numericamountstrings (400). - Backend sanity filter. Backend drops any provider quote whose output is less than 1% of the best provider's output. Catches Persistence-side mislabels (notably Across reporting same-asset-bridge quotes against cross-asset requests). See
backend/README.md. - Calldata sanity check. Before
eth_sendTransaction,useSwapExecution.tsscans tx calldata for the connected wallet's address. If the encoded recipient doesn't match, the swap is aborted.
Wallet auth uses Privy. Set VITE_PRIVY_APP_ID to enable it. Without it, a demo wallet handles development.
The wallet address is the only identity used. No email, password, or personal data is collected by the frontend or backend. Wallet-address-only.
Styles live in src/index.css. CSS custom properties for colors, spacing, shadows, fonts, and transitions. All classes are prefixed hf-.
Core tokens (Persistence brand palette):
--hf-primary: #E59636 /* Persistence orange */
--hf-text-primary: #1D1306 /* near-black */
--hf-font-headline: 'Poppins'
--hf-font-body: 'Poppins'
--hf-mono: 'JetBrains Mono'Animations are powered by Framer Motion. Tooltips on info icons (.hf-fee-info-icon .hf-tooltip) use absolute positioning relative to the icon and sit above their parent. The route-detail drawer toggles overflow: hidden → visible via transitionEnd so tooltips can escape the drawer once it's fully expanded.
npm run dev # start dev server with hot reload
npm run build # production build to dist/
npm run preview # preview the production build locally