diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e4a845a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing Guide + +## Table of Contents +- Getting Started +- Branching and Commits +- Coding Standards +- UI/UX Conventions +- Pull Request Checklist +- Issue Reporting + +## Getting Started +- Fork the repository and create a feature branch from `main`. +- Install dependencies and set up environment variables: + - Copy `.env.example` to `.env` + - Set `NEXT_PUBLIC_PROJECT_ID` from Reown Cloud (used in `utils/config.ts:16`) +- Run the app locally: + - `npm run dev` + +## Branching and Commits +- Use descriptive branch names, e.g., `feat/explorer-pagination`, `fix/vault-sync-error`. +- Prefer Conventional Commits: + - `feat:` for new features + - `fix:` for bug fixes + - `docs:` for documentation changes + - `refactor:` for code refactors + - `chore:` for maintenance tasks + +## Coding Standards +- TypeScript strict mode is enabled (`tsconfig.json:11`); keep types explicit. +- Use path alias `@/*` for imports (`tsconfig.json:25`). +- Run `npm run lint` and fix issues before committing. +- Follow Prettier formatting (`.prettierrc`). +- Keep functions small and reusable; prefer composable components under `components/`. + +## UI/UX Conventions +- Use shadcn/ui components from `components/ui/` for consistency. +- Tailwind for styling; avoid inline styles unless necessary. +- Keep interactive features client-side (`'use client'`) when needed. +- Keep animations lightweight (Lottie assets under `public/animations/`). + +## Pull Request Checklist +- Clear PR title and summary of changes. +- Reference related issues. +- Include code references when relevant: + - Env usage: `utils/config.ts:16` + - Static export: `next.config.mjs:4`, `next.config.mjs:8` + - Vault sync: `app/[vaultId]/InteractionClient.tsx:311` +- Ensure `npm run lint` passes. +- Verify local build if applicable: `npm run build`. + +## Issue Reporting +- Provide steps to reproduce, expected vs actual behavior, and environment details. +- Attach console errors or network logs when relevant. diff --git a/README.md b/README.md index db81f22..381d878 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,190 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# hodlCoin Staking Platform (Web UI) -## Getting Started +Self-stabilizing staking vaults where the price is mathematically proven to always increase. Unstaking fees reward vault creators and long-term stakers. -First, run the development server: +## Table of Contents +- Overview +- Features +- Tech Stack +- Architecture +- File Structure +- Setup +- Environment Variables +- Development +- Debugging +- Deployment +- Contributing +- License for Assets -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +## Features +- Create new staking vaults for ERC‑20 tokens (`/createVault`) +- Explore all vaults across supported chains (`/explorer`) +- Track your created vaults (`/myVaults`) +- Save and revisit favorite vaults (`/favorites`) +- Detailed vault view: balances, fees, price, and manual sync (`/v/[vaultId]`) +- Wallet connection via RainbowKit/Wagmi with multi-chain support +- Fast UI built with Tailwind and shadcn/ui components +- Local caching using IndexedDB for snappy UX -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Tech Stack +- Next.js 14 (App Router) + TypeScript +- Tailwind CSS + shadcn/ui (Radix primitives) +- Wagmi + RainbowKit + Reown AppKit +- Ethers v5 and Viem utilities +- TanStack Query +- Lottie animations -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Architecture +- Wallet and chain: + - Wagmi/RainbowKit configuration reads `NEXT_PUBLIC_PROJECT_ID` in `utils/config.ts:16`. + - Custom chain definitions under `components/CitreaTestnet.tsx` and `components/EthereumClassic.tsx`. +- Data access: + - Vault discovery via on-chain factories (`utils/addresses.ts`, ABIs under `utils/contracts/`). + - Cached lists and details stored in IndexedDB (`utils/indexedDB.ts`). +- UI pages: + - App Router under `app/` with route segments for explorer, favorites, my vaults, vault detail. + - Vault detail client handles sync, fees, balances (`app/[vaultId]/InteractionClient.tsx:76`, `app/[vaultId]/InteractionClient.tsx:311`). +- Styling and components: + - Tailwind and shadcn/ui components in `components/ui/`. + - Reusable feature modules in `components/Vault/`, `components/Explorer/`, etc. -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. +## File Structure +``` +app/ + [vaultId]/ # Vault detail route + InteractionClient.tsx + page.tsx + createVault/ # Create vault flow + explorer/ # Explore vaults + favorites/ # Favorite vaults + myVaults/ # User-created vaults + layout.tsx # Root layout and providers + page.tsx # Home page +components/ + Home/ # Hero + About sections + Explorer/ # Explorer cards and lists + FavoriteVaults/ # Favorites listing + FindVault/ # Vault search dialog + Vault/ # Vault UI: actions, hero, info + hooks/ # UI hooks (matrix effect, token list) + ui/ # shadcn/ui primitives + ChainDropdown.tsx # Chain selector + Header.tsx, Footer.tsx, NavBar.tsx, etc. +contexts/ + EthersContext.tsx # Metamask context values +providers/ + WalletProvider.tsx # Wagmi/RainbowKit provider + theme-provider.tsx # Theme support +utils/ + contracts/ # ABIs for HodlCoin, ERC20, Factory + addresses.ts # Chain factory addresses + chains.ts # Chain name helpers + config.ts # Wagmi/RainbowKit config + favorites.ts # Favorite handling + indexedDB.ts # Caching layer + props.ts # Shared types +lib/ + utils.ts # Utility functions +public/ + animations/ # Lottie JSON + images/ # Avatars and brand assets + hodlcoin*.svg/png # Logos and license +next.config.mjs # Static export + basePath for GH Pages +tailwind.config.ts # Tailwind config +tsconfig.json # TS strict mode + path aliases +.eslintrc.json # ESLint config +.prettierrc # Prettier config +.env.example # Required env vars +``` -## Local Setup with Project ID +## Requirements +- Node.js 18+ +- npm, pnpm, or yarn +- A Web3 wallet (e.g., MetaMask) or WalletConnect-compatible wallet -Before running the project locally, you need to set up an environment variable with your Project ID. Follow these steps: +## Quick Start +1. Clone the repository and install dependencies: + ```bash + npm install + # or: yarn, pnpm + ``` +2. Configure environment variables: + - Copy `.env.example` to `.env` + - Set `NEXT_PUBLIC_PROJECT_ID` with your Reown Cloud project ID + - Optionally set `NEXT_PUBLIC_API_URL` if an API is used -1. **Create a `.env` File** - In the root directory of the project, create a file named `.env` and add the following line: - ```env - NEXT_PUBLIC_PROJECT_ID=your-project-id +3. Run the dev server: + ```bash + npm run dev ``` + Visit `http://localhost:3000` + +## Environment Variables +- `NEXT_PUBLIC_PROJECT_ID`: Required for WalletConnect/Reown integration + Used in `utils/config.ts:16`. +- `NEXT_PUBLIC_API_URL`: Optional backend/API base URL (if applicable) -2. **Obtain Your Project ID** - To get your `your-project-id` value: - - Visit [Reown Cloud](https://cloud.reown.com/sign-in). - - Create an account or log in if you already have one. - - Create a new project within the dashboard. - - Once the project is created, locate your project key (this may be labeled as "Project ID" or "API Key"). - - Copy the key and paste it into your `.env` file in place of `your-project-id`. +## Scripts +- `npm run dev`: Start development server +- `npm run build`: Build for production (static export to `out`) +- `npm run start`: Start production server +- `npm run lint`: Run Next.js ESLint checks -After setting up the environment variable, you can run the development server as described above. +## Development +- TypeScript + - Strict mode enabled in `tsconfig.json:11` + - Path alias `@/*` to project root in `tsconfig.json:25` +- Linting and formatting + - ESLint: `next/core-web-vitals` (`.eslintrc.json:2`) + - Prettier config in `.prettierrc` +- UI conventions + - shadcn/ui primitives in `components/ui/` + - Tailwind utility-first CSS in `app/globals.css` and components +- Data and caching + - IndexedDB manager in `utils/indexedDB.ts` for vault lists/details caching + +## Debugging +- Wallet/project ID issues + - Ensure `NEXT_PUBLIC_PROJECT_ID` is set in `.env` and not empty + - Configuration reads this in `utils/config.ts:16` +- Chain/client errors + - If `getPublicClient` returns null, verify supported chains and wallet network + - Check `components/ChainDropdown.tsx` and `utils/addresses.ts` for chain mapping +- Stale cache + - Use manual sync in vault detail (`app/[vaultId]/InteractionClient.tsx:311`) + - Or clear site data via DevTools: Application β†’ Storage β†’ Clear Site Data +- Static export routing + - `next.config.mjs:8` uses `basePath: '/hodlCoin-Solidity-WebUI'`. Ensure your GitHub Pages repo name matches + - When serving under a subpath, all links and asset paths include the base path automatically +- Common local fixes + - Delete `.next/` and `out/`, reinstall dependencies, and retry build/run + - Confirm Node 18+ and a working `npm`/`yarn`/`pnpm` installation + +## Deployment +This project is configured for static export: +- `next.config.mjs:4` sets `output: 'export'` and `distDir: 'out'` +- `next.config.mjs:8` sets `basePath: '/hodlCoin-Solidity-WebUI'` for GitHub Pages + +Build and publish: +```bash +npm run build +npx gh-pages -d out +``` +You can deploy `out/` to any static host (GitHub Pages, Vercel static, Netlify, etc.). -## Learn More +## Project Structure +- `app/`: Routes and pages (App Router) +- `components/`: UI and feature components (e.g., Vault, Explorer, Home) +- `providers/`: Wagmi/RainbowKit and theme providers +- `utils/`: Chains, addresses, ABIs, config, favorites, caching +- `public/`: Static assets and animations -To learn more about Next.js, take a look at the following resources: +## Contributing +- Open an issue with a clear description and steps to reproduce +- Make focused PRs with clear titles and descriptions +- Run `npm run lint` and ensure builds pass before submitting +- See `CONTRIBUTING.md` for detailed guidelines -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Assets License +Logo and brand assets are licensed separately. See `public/hodlcoin-logo-license.md`. diff --git a/SECURITY_TEST_REPORT.md b/SECURITY_TEST_REPORT.md new file mode 100644 index 0000000..0a5c6ce --- /dev/null +++ b/SECURITY_TEST_REPORT.md @@ -0,0 +1,44 @@ +# Security Test Report (Burp Suite) + +## Scope +- App: hodlCoin-Solidity-WebUI +- Pages: Home, Create Vault, Explorer, Favorites, My Vaults, Vault Detail +- Assets: Static export + +## Environment +- Local dev server: `npm run dev` +- Browser proxied through Burp Suite +- Wallet: MetaMask (test accounts) + +## Methodology +- Passive scan while navigating all pages +- Active scan on form inputs and query params +- Manual checks: + - Input validation for vault address and chain ID + - Navigation links and route handling + - IndexedDB caching operations + - External links and target attributes + +## Findings +- Cross-Site Scripting (XSS): No injection vectors observed; no `dangerouslySetInnerHTML` usage. +- Open Redirect: Navigation uses internal router; no user-controlled redirect targets. +- Input Validation: + - Address and chain ID validation added via `utils/validation.ts`. + - Invalid values prevented from routing or contract reads. +- CSP/Headers: Static export limits header enforcement; recommend hosting-level CSP. +- External Links: Transaction explorer links use `target="_blank"`. Recommend `rel="noreferrer"`. + +## Recommendations +- Enforce CSP at hosting (script-src 'self', object-src 'none'). +- Add `rel="noreferrer"` to external links. +- Monitor dependency updates for security patches. + +## Test Results +- Create Vault: Validations block invalid inputs and negative fees. +- Explorer Card: Enter Vault guarded by address and chain ID checks. +- Find Vault: Validations and error messages for incorrect inputs. +- Vault Detail: Path and query parsing validated; manual sync safe. + +## Conclusion +- Current UI resistant to basic client-side attack vectors. +- Hosting CSP and external link rel attributes recommended. diff --git a/app/[vaultId]/InteractionClient.tsx b/app/[vaultId]/InteractionClient.tsx index 8e6cfbd..e4002fe 100644 --- a/app/[vaultId]/InteractionClient.tsx +++ b/app/[vaultId]/InteractionClient.tsx @@ -7,11 +7,12 @@ import VaultInformation from '@/components/Vault/VaultInformation' import { ERC20Abi } from '@/utils/contracts/ERC20' import { vaultsProps } from '@/utils/props' import { useEffect, useState } from 'react' -import { useAccount } from 'wagmi' +import { useAccount, useChainId } from 'wagmi' import { readContract, getPublicClient } from '@wagmi/core' import { config } from '@/utils/config' import { HodlCoinAbi } from '@/utils/contracts/HodlCoin' -import { useSearchParams, useRouter } from 'next/navigation' +import { useSearchParams, useRouter, useParams } from 'next/navigation' +import { parseVaultPathParam, normalizeAddress, isValidChainId } from '@/utils/validation' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { useMatrixEffect } from '@/components/hooks/useMatrixEffect' @@ -22,8 +23,10 @@ import { getChainName } from '@/utils/chains' export default function InteractionClient() { const searchParams = useSearchParams() + const params = useParams() const router = useRouter() const account = useAccount() + const activeChainId = useChainId() const matrixRef = useMatrixEffect(0.15, 2) const [isLoading, setIsLoading] = useState(true) @@ -61,16 +64,35 @@ export default function InteractionClient() { vaultCreatorFee: 0, stableOrderFee: 0, }) + const [lastSyncedAt, setLastSyncedAt] = useState(null) useEffect(() => { const vault = searchParams.get('vault') const chain = searchParams.get('chainId') if (vault && chain) { - setVaultAddress(vault as `0x${string}`) - setChainId(Number(chain)) + const naddr = normalizeAddress(vault) + const cid = Number(chain) + if (naddr && isValidChainId(cid)) { + setVaultAddress(naddr) + setChainId(cid) + } else { + setError('Invalid vault address or chain ID') + } + return + } + const pv = (params as any)?.vaultId + if (typeof pv === 'string' && pv.length > 0) { + const { address, chainId: parsedCid } = parseVaultPathParam(pv) + const cid = parsedCid ?? activeChainId ?? 0 + if (address && isValidChainId(cid)) { + setVaultAddress(address) + setChainId(cid) + } else { + setError('Invalid vault address or chain ID') + } } - }, [searchParams]) + }, [searchParams, params, activeChainId]) const getVaultsData = async (forceRefresh: boolean = false) => { @@ -191,6 +213,7 @@ export default function InteractionClient() { setCoinName(name as string) setCoinSymbol(symbol as string) setHodlCoinSymbol(hodlSymbol as string) + setLastSyncedAt(Date.now()) } catch (error) { console.error('Error fetching vault data:', error) setError('Failed to load vault data. Please check the vault address and chain ID.') @@ -266,7 +289,7 @@ export default function InteractionClient() { }), ]) - setBalances(prev => ({ + setBalances((prev: typeof balances) => ({ ...prev, coinReserve: Number(coinReserveOnChain) / 10 ** 18, coinBalance: Number(coinBalanceOnChain) / 10 ** 18, @@ -298,7 +321,7 @@ export default function InteractionClient() { }), ]) - setBalances(prev => ({ + setBalances((prev: typeof balances) => ({ ...prev, hodlCoinSupply: Number(hodlCoinSupplyOnChain) / 10 ** 18, priceHodl: Number(priceHodlOnChain) / 100000, @@ -345,6 +368,7 @@ export default function InteractionClient() { title: 'Sync Complete', description: 'All vault data has been refreshed successfully.', }) + setLastSyncedAt(Date.now()) } catch (error) { console.error('❌ Manual sync failed:', error) toast({ @@ -490,6 +514,10 @@ export default function InteractionClient() { + +
+ {lastSyncedAt ? `Last synced ${new Date(lastSyncedAt).toLocaleTimeString()}` : ''} +
diff --git a/components/CreateVault/CreateForm.tsx b/components/CreateVault/CreateForm.tsx index 6f06b79..e096d1b 100644 --- a/components/CreateVault/CreateForm.tsx +++ b/components/CreateVault/CreateForm.tsx @@ -25,6 +25,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { TokenSchema } from '../hooks/useTokenList' import TokenPicker from './TokenPicker' import { getChainExplorer, getTransactionUrl } from '@/utils/chains' +import { normalizeAddress, isValidChainId } from '@/utils/validation' export default function CreateForm() { @@ -103,7 +104,7 @@ export default function CreateForm() { if (!coinName) Errors.coinName = 'Name for hodlCoin is required' if (!symbol) Errors.symbol = 'Symbol for hodlCoin is required' - if (!coin) Errors.coin = 'Underlying asset is required' + if (!coin || !normalizeAddress(coin)) Errors.coin = 'Valid ERC20 address is required' if (!vaultCreator) Errors.vaultCreator = 'Vault creator address is required' if (vaultFee === '') Errors.vaultFee = 'Vault fee is required' if (vaultCreatorFee === '') @@ -111,6 +112,7 @@ export default function CreateForm() { if (Number(vaultFee) < 0) Errors.vaultFee = 'Vault fee cannot be negative' if (Number(vaultCreatorFee) < 0) Errors.vaultCreatorFee = 'Vault creator fee cannot be negative' + if (!isValidChainId(config.state.chainId)) Errors.coin = 'Unsupported chain' setErrors(Errors) return Object.keys(Errors).length === 0 @@ -438,6 +440,7 @@ export default function CreateForm() { - + @@ -158,15 +161,15 @@ export default function CardExplorer({ vault }: { vault: ExtendedVaultProps }) { e.currentTarget.style.color = 'hsl(50 100% 45%)'} - onMouseLeave={(e) => e.currentTarget.style.color = ''}> + onMouseEnter={(e: any) => e.currentTarget.style.color = 'hsl(50 100% 45%)'} + onMouseLeave={(e: any) => e.currentTarget.style.color = ''}> Hodl Price
e.currentTarget.style.color = 'hsl(50 100% 45%)'} - onMouseLeave={(e) => e.currentTarget.style.color = ''}> + onMouseEnter={(e: any) => e.currentTarget.style.color = 'hsl(50 100% 45%)'} + onMouseLeave={(e: any) => e.currentTarget.style.color = ''}> {vault.priceHodl !== null && vault.priceHodl !== undefined ? `${(Number(vault.priceHodl) / 100000).toFixed(5)}` : 'N/A' diff --git a/components/FindVault/FindVaultForm.tsx b/components/FindVault/FindVaultForm.tsx index 6eaee11..8523359 100644 --- a/components/FindVault/FindVaultForm.tsx +++ b/components/FindVault/FindVaultForm.tsx @@ -15,6 +15,7 @@ import { useState } from 'react' import { Button } from '../ui/button' import { useRouter } from 'next/navigation' import { Search, ExternalLink } from 'lucide-react' +import { normalizeAddress, parseChainIdInput } from '@/utils/validation' interface PromptDialogBoxProps { children: React.ReactNode @@ -24,10 +25,16 @@ export default function FindVault({ children }: PromptDialogBoxProps) { const router = useRouter() const [vaultAddress, setVaultAddress] = useState('') const [chainId, setChainId] = useState(0) + const [addrError, setAddrError] = useState(null) + const [chainError, setChainError] = useState(null) const handleContinue = () => { - if (vaultAddress && chainId) { - router.push(`/v?chainId=${chainId}&vault=${vaultAddress}`) + const naddr = normalizeAddress(vaultAddress) + const validChain = parseChainIdInput(String(chainId)) + setAddrError(naddr ? null : 'Invalid address') + setChainError(validChain !== null ? null : 'Unsupported chain') + if (naddr && validChain !== null) { + router.push(`/${naddr}?chainId=${validChain}`) } } @@ -61,10 +68,15 @@ export default function FindVault({ children }: PromptDialogBoxProps) { type='number' placeholder='e.g., 1 (Ethereum), 534351 (Scroll Sepolia)' value={chainId || ''} - onChange={e => setChainId(Number(e.target.value))} + onChange={e => { + const v = parseChainIdInput(e.target.value) + setChainId(v ?? 0) + setChainError(v !== null ? null : 'Unsupported chain') + }} className="bg-background/50 backdrop-blur-sm border-primary/30 focus:border-primary/60 transition-all duration-300 hover:border-primary/50 text-foreground placeholder:text-muted-foreground" /> + {chainError && {chainError}}
@@ -76,10 +88,14 @@ export default function FindVault({ children }: PromptDialogBoxProps) { id='vault-address' placeholder='0x1234567890abcdef...' value={vaultAddress} - onChange={e => setVaultAddress(e.target.value)} + onChange={e => { + setVaultAddress(e.target.value) + setAddrError(normalizeAddress(e.target.value) ? null : 'Invalid address') + }} className="bg-background/50 backdrop-blur-sm border-primary/30 focus:border-primary/60 transition-all duration-300 hover:border-primary/50 text-foreground placeholder:text-muted-foreground font-mono" /> + {addrError && {addrError}}
{vaultAddress && chainId && ( diff --git a/package.json b/package.json index 4c0be4f..0abd2d7 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "typecheck": "tsc --noEmit" }, "dependencies": { "@lottiefiles/react-lottie-player": "^3.5.3", diff --git a/types/shims.d.ts b/types/shims.d.ts new file mode 100644 index 0000000..91ff880 --- /dev/null +++ b/types/shims.d.ts @@ -0,0 +1,20 @@ +declare module 'lucide-react'; +declare module 'wagmi'; +declare module '@wagmi/core'; +declare module 'next/navigation'; +declare module 'next/link'; +declare module 'react' { + export function useState(initialState: T | (() => T)): [T, (value: T | ((prev: T) => T)) => void]; + export function useEffect(effect: (...args: any[]) => any, deps?: any[]): void; + export function useCallback any>(callback: T, deps: any[]): T; +} + +declare namespace JSX { + interface IntrinsicElements { + [elemName: string]: any; + } +} + +declare namespace React { + type MouseEvent = any; +} diff --git a/utils/validation.ts b/utils/validation.ts new file mode 100644 index 0000000..242da02 --- /dev/null +++ b/utils/validation.ts @@ -0,0 +1,38 @@ +import { getSupportedChainIds } from './chains' + +export const isHexAddress = (addr: string): boolean => /^0x[a-fA-F0-9]{40}$/.test(addr) + +export const normalizeAddress = (addr: string): `0x${string}` | null => { + if (!addr) return null + const a = addr.trim() + return isHexAddress(a) ? (a as `0x${string}`) : null +} + +export const isValidChainId = (id: number): boolean => { + if (!Number.isFinite(id)) return false + return getSupportedChainIds().includes(id) +} + +export const parseVaultPathParam = (pv: string): { address: `0x${string}` | null; chainId: number | null } => { + if (!pv) return { address: null, chainId: null } + let addr = pv + let cid: number | null = null + if (pv.includes(':')) { + const [a, c] = pv.split(':') + addr = a + cid = Number(c) + } else if (pv.includes('@')) { + const [a, c] = pv.split('@') + addr = a + cid = Number(c) + } + const naddr = normalizeAddress(addr) + const validCid = cid !== null && isValidChainId(cid) ? cid : null + return { address: naddr, chainId: validCid } +} + +export const parseChainIdInput = (val: string): number | null => { + const num = Number(val) + if (!Number.isFinite(num)) return null + return isValidChainId(num) ? num : null +}