diff --git a/docs/images/USDT(hez)logo.png b/docs/images/USDT(hez)logo.png new file mode 100644 index 0000000..30386dc Binary files /dev/null and b/docs/images/USDT(hez)logo.png differ diff --git a/multiformats_package.json b/multiformats_package.json new file mode 100644 index 0000000..3843b15 --- /dev/null +++ b/multiformats_package.json @@ -0,0 +1,307 @@ +{ + "name": "multiformats", + "version": "12.1.3", + "description": "Interface for multihash, multicodec, multibase and CID", + "author": "Mikeal Rogers (https://www.mikealrogers.com/)", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/multiformats/js-multiformats#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/multiformats/js-multiformats.git" + }, + "bugs": { + "url": "https://github.com/multiformats/js-multiformats/issues" + }, + "keywords": [ + "ipfs", + "ipld", + "multiformats" + ], + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + }, + "type": "module", + "types": "./dist/types/src/index.d.ts", + "typesVersions": { + "*": { + "*": [ + "*", + "dist/types/*", + "dist/types/src/*", + "dist/types/src/*/index" + ], + "src/*": [ + "*", + "dist/types/*", + "dist/types/src/*", + "dist/types/src/*/index" + ] + } + }, + "files": [ + "CHANGELOG.md", + "examples", + "LICENSE*", + "src", + "test", + "tsconfig.json", + "dist", + "vendor", + "!**/*.tsbuildinfo", + "!test/ts-use/node_modules" + ], + "exports": { + ".": { + "types": "./dist/types/src/index.d.ts", + "import": "./src/index.js" + }, + "./bases/base10": { + "types": "./dist/types/src/bases/base10.d.ts", + "import": "./src/bases/base10.js" + }, + "./bases/base16": { + "types": "./dist/types/src/bases/base16.d.ts", + "import": "./src/bases/base16.js" + }, + "./bases/base2": { + "types": "./dist/types/src/bases/base2.d.ts", + "import": "./src/bases/base2.js" + }, + "./bases/base256emoji": { + "types": "./dist/types/src/bases/base256emoji.d.ts", + "import": "./src/bases/base256emoji.js" + }, + "./bases/base32": { + "types": "./dist/types/src/bases/base32.d.ts", + "import": "./src/bases/base32.js" + }, + "./bases/base36": { + "types": "./dist/types/src/bases/base36.d.ts", + "import": "./src/bases/base36.js" + }, + "./bases/base58": { + "types": "./dist/types/src/bases/base58.d.ts", + "import": "./src/bases/base58.js" + }, + "./bases/base64": { + "types": "./dist/types/src/bases/base64.d.ts", + "import": "./src/bases/base64.js" + }, + "./bases/base8": { + "types": "./dist/types/src/bases/base8.d.ts", + "import": "./src/bases/base8.js" + }, + "./bases/identity": { + "types": "./dist/types/src/bases/identity.d.ts", + "import": "./src/bases/identity.js" + }, + "./bases/interface": { + "types": "./dist/types/src/bases/interface.d.ts", + "import": "./src/bases/interface.js" + }, + "./basics": { + "types": "./dist/types/src/basics.d.ts", + "import": "./src/basics.js" + }, + "./block": { + "types": "./dist/types/src/block.d.ts", + "import": "./src/block.js" + }, + "./block/interface": { + "types": "./dist/types/src/block/interface.d.ts", + "import": "./src/block/interface.js" + }, + "./bytes": { + "types": "./dist/types/src/bytes.d.ts", + "import": "./src/bytes.js" + }, + "./cid": { + "types": "./dist/types/src/cid.d.ts", + "import": "./src/cid.js" + }, + "./codecs/interface": { + "types": "./dist/types/src/codecs/interface.d.ts", + "import": "./src/codecs/interface.js" + }, + "./codecs/json": { + "types": "./dist/types/src/codecs/json.d.ts", + "import": "./src/codecs/json.js" + }, + "./codecs/raw": { + "types": "./dist/types/src/codecs/raw.d.ts", + "import": "./src/codecs/raw.js" + }, + "./hashes/digest": { + "types": "./dist/types/src/hashes/digest.d.ts", + "import": "./src/hashes/digest.js" + }, + "./hashes/hasher": { + "types": "./dist/types/src/hashes/hasher.d.ts", + "import": "./src/hashes/hasher.js" + }, + "./hashes/identity": { + "types": "./dist/types/src/hashes/identity.d.ts", + "import": "./src/hashes/identity.js" + }, + "./hashes/interface": { + "types": "./dist/types/src/hashes/interface.d.ts", + "import": "./src/hashes/interface.js" + }, + "./hashes/sha1": { + "types": "./dist/types/src/hashes/sha1.d.ts", + "browser": "./src/hashes/sha1-browser.js", + "import": "./src/hashes/sha1.js" + }, + "./hashes/sha2": { + "types": "./dist/types/src/hashes/sha2.d.ts", + "browser": "./src/hashes/sha2-browser.js", + "import": "./src/hashes/sha2.js" + }, + "./interface": { + "types": "./dist/types/src/interface.d.ts", + "import": "./src/interface.js" + }, + "./link": { + "types": "./dist/types/src/link.d.ts", + "import": "./src/link.js" + }, + "./link/interface": { + "types": "./dist/types/src/link/interface.d.ts", + "import": "./src/link/interface.js" + }, + "./traversal": { + "types": "./dist/types/src/traversal.d.ts", + "import": "./src/traversal.js" + } + }, + "browser": { + "./hashes/sha1": "./src/hashes/sha1-browser.js", + "./src/hashes/sha1.js": "./src/hashes/sha1-browser.js", + "./hashes/sha2": "./src/hashes/sha2-browser.js", + "./src/hashes/sha2.js": "./src/hashes/sha2-browser.js" + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "sourceType": "module" + } + }, + "release": { + "branches": [ + "master" + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "breaking": true, + "release": "major" + }, + { + "revert": true, + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "docs", + "release": "patch" + }, + { + "type": "test", + "release": "patch" + }, + { + "type": "deps", + "release": "patch" + }, + { + "scope": "no-release", + "release": false + } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "chore", + "section": "Trivial Changes" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "deps", + "section": "Dependencies" + }, + { + "type": "test", + "section": "Tests" + } + ] + } + } + ], + "@semantic-release/changelog", + "@semantic-release/npm", + "@semantic-release/github", + "@semantic-release/git" + ] + }, + "scripts": { + "clean": "aegir clean", + "lint": "aegir lint", + "build": "aegir build", + "release": "aegir release", + "docs": "aegir docs", + "test": "npm run lint && npm run test:node && npm run test:chrome && npm run test:ts", + "test:ts": "npm run test --prefix test/ts-use", + "test:node": "aegir test -t node --cov", + "test:chrome": "aegir test -t browser --cov", + "test:chrome-webworker": "aegir test -t webworker", + "test:firefox": "aegir test -t browser -- --browser firefox", + "test:firefox-webworker": "aegir test -t webworker -- --browser firefox", + "test:electron-main": "aegir test -t electron-main" + }, + "devDependencies": { + "@stablelib/sha256": "^1.0.1", + "@stablelib/sha512": "^1.0.1", + "@types/node": "^20.3.1", + "aegir": "^41.0.0", + "buffer": "^6.0.3", + "cids": "^1.1.9", + "crypto-hash": "^3.0.0" + }, + "aegir": { + "test": { + "target": [ + "node", + "browser" + ] + } + } +} diff --git a/package.json b/package.json index 4ca0bec..e651d82 100644 --- a/package.json +++ b/package.json @@ -97,11 +97,11 @@ "source-map-explorer": "^2.5.3" }, "resolutions": { - "@pezkuwi/api": "^16.5.11", - "@pezkuwi/api-augment": "^16.5.11", - "@pezkuwi/api-base": "^16.5.11", - "@pezkuwi/api-contract": "^16.5.11", - "@pezkuwi/api-derive": "^16.5.11", + "@pezkuwi/api": "^16.5.22", + "@pezkuwi/api-augment": "^16.5.22", + "@pezkuwi/api-base": "^16.5.22", + "@pezkuwi/api-contract": "^16.5.22", + "@pezkuwi/api-derive": "^16.5.22", "@pezkuwi/extension-dapp": "0.62.20", "@pezkuwi/extension-inject": "^0.62.20", "@pezkuwi/hw-ledger": "^14.0.11", @@ -110,9 +110,9 @@ "@pezkuwi/phishing": "^0.25.28", "@pezkuwi/rpc-augment": "^16.5.11", "@pezkuwi/rpc-core": "^16.5.11", - "@pezkuwi/rpc-provider": "^16.5.11", + "@pezkuwi/rpc-provider": "^16.5.22", "@pezkuwi/typegen": "^16.5.11", - "@pezkuwi/types": "^16.5.11", + "@pezkuwi/types": "^16.5.22", "@pezkuwi/types-augment": "^16.5.11", "@pezkuwi/types-codec": "^16.5.11", "@pezkuwi/types-create": "^16.5.11", diff --git a/packages/apps-config/package.json b/packages/apps-config/package.json index 6394229..f7f307e 100644 --- a/packages/apps-config/package.json +++ b/packages/apps-config/package.json @@ -38,11 +38,11 @@ "@parallel-finance/type-definitions": "2.0.1", "@peaqnetwork/type-definitions": "0.0.4", "@pendulum-chain/type-definitions": "0.3.8", - "@pezkuwi/api": "^16.5.2", - "@pezkuwi/api-derive": "^16.5.2", + "@pezkuwi/api": "^16.5.22", + "@pezkuwi/api-derive": "^16.5.22", "@pezkuwi/networks": "^14.0.5", "@pezkuwi/react-identicon": "^3.17.1", - "@pezkuwi/types": "^16.5.2", + "@pezkuwi/types": "^16.5.22", "@pezkuwi/types-codec": "^16.5.2", "@pezkuwi/util": "^14.0.5", "@pezkuwi/util-crypto": "^14.0.5", diff --git a/packages/apps/postcss.config.cjs b/packages/apps/postcss.config.cjs new file mode 100644 index 0000000..ed53bf2 --- /dev/null +++ b/packages/apps/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + 'autoprefixer': {}, + }, +}; diff --git a/packages/apps/public/images/PEZ_Token_Logo_512.png b/packages/apps/public/images/PEZ_Token_Logo_512.png new file mode 100644 index 0000000..53a990e Binary files /dev/null and b/packages/apps/public/images/PEZ_Token_Logo_512.png differ diff --git a/packages/apps/public/images/Pezkuwi_Logo_Horizontal_Pink_Black.png b/packages/apps/public/images/Pezkuwi_Logo_Horizontal_Pink_Black.png new file mode 100644 index 0000000..a675ec6 Binary files /dev/null and b/packages/apps/public/images/Pezkuwi_Logo_Horizontal_Pink_Black.png differ diff --git a/packages/apps/public/images/Pezkuwi_Logo_Horizontal_White.png b/packages/apps/public/images/Pezkuwi_Logo_Horizontal_White.png new file mode 100644 index 0000000..602cd93 Binary files /dev/null and b/packages/apps/public/images/Pezkuwi_Logo_Horizontal_White.png differ diff --git a/packages/apps/public/images/hez_token_512.png b/packages/apps/public/images/hez_token_512.png new file mode 100644 index 0000000..9611619 Binary files /dev/null and b/packages/apps/public/images/hez_token_512.png differ diff --git a/packages/apps/public/pezkuwi-logo.png b/packages/apps/public/pezkuwi-logo.png new file mode 100644 index 0000000..602cd93 Binary files /dev/null and b/packages/apps/public/pezkuwi-logo.png differ diff --git a/packages/apps/src/ModernApp.tsx b/packages/apps/src/ModernApp.tsx new file mode 100644 index 0000000..a520dae --- /dev/null +++ b/packages/apps/src/ModernApp.tsx @@ -0,0 +1,73 @@ +import React, { lazy, Suspense } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { ThemeProvider } from '@/components/theme-provider'; +import { AppProvider } from '@/contexts/AppContext'; +import { PezkuwiProvider } from '@/contexts/PezkuwiContext'; +import { WalletProvider } from '@/contexts/WalletContext'; +import { WebSocketProvider } from '@/contexts/WebSocketContext'; +import { IdentityProvider } from '@/contexts/IdentityContext'; +import { AuthProvider } from '@/contexts/AuthContext'; +import { DashboardProvider } from '@/contexts/DashboardContext'; +import { ReferralProvider } from '@/contexts/ReferralContext'; +import { ProtectedRoute } from '@/components/ProtectedRoute'; +import { Toaster } from '@/components/ui/toaster'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { initSentry } from '@/lib/sentry'; +import './tailwind.css'; +import './i18n/config'; + +// Initialize Sentry error monitoring +initSentry(); + +// Lazy load wallet-related pages +const WalletDashboard = lazy(() => import('./pages/WalletDashboard')); +const ProfileSettings = lazy(() => import('./pages/ProfileSettings')); +const NotFound = lazy(() => import('@/pages/NotFound')); + +// Loading component +const PageLoader = () => ( +
+
+
+); + +function ModernApp() { + const endpoint = (process.env.WS_URL as string) || 'wss://beta-rpc.pezkuwichain.io:19944'; + + return ( + + + + + + + + + + + }> + + } /> + + + + } /> + } /> + + + + + + + + + + + + + + ); +} + +export default ModernApp; diff --git a/packages/apps/src/components/AccountBalance.tsx b/packages/apps/src/components/AccountBalance.tsx new file mode 100644 index 0000000..ce11adb --- /dev/null +++ b/packages/apps/src/components/AccountBalance.tsx @@ -0,0 +1,789 @@ +import React, { useEffect, useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Wallet, TrendingUp, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; +import { AddTokenModal } from './AddTokenModal'; +import { TransferModal } from './TransferModal'; +import { getAllScores, type UserScores } from '@pezkuwi/lib/scores'; + +interface TokenBalance { + assetId: number; + symbol: string; + name: string; + balance: string; + decimals: number; + usdValue: number; +} + +export const AccountBalance: React.FC = () => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const [balance, setBalance] = useState<{ + free: string; + reserved: string; + total: string; + }>({ + free: '0', + reserved: '0', + total: '0', + }); + const [pezBalance, setPezBalance] = useState('0'); + const [usdtBalance, setUsdtBalance] = useState('0'); + const [hezUsdPrice, setHezUsdPrice] = useState(0); + const [pezUsdPrice, setPezUsdPrice] = useState(0); + const [scores, setScores] = useState({ + trustScore: 0, + referralScore: 0, + stakingScore: 0, + tikiScore: 0, + totalScore: 0 + }); + const [loadingScores, setLoadingScores] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [otherTokens, setOtherTokens] = useState([]); + const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false); + const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [selectedTokenForTransfer, setSelectedTokenForTransfer] = useState(null); + const [customTokenIds, setCustomTokenIds] = useState(() => { + const stored = localStorage.getItem('customTokenIds'); + return stored ? JSON.parse(stored) : []; + }); + + // Helper function to get asset decimals + const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ and others have 12 decimals by default + }; + + // Helper to decode hex string to UTF-8 + const hexToString = (hex: string): string => { + if (!hex || hex === '0x') return ''; + try { + const hexStr = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []); + return new TextDecoder('utf-8').decode(bytes).replace(/\0/g, ''); + } catch { + return ''; + } + }; + + // Token logo mapping + const TOKEN_LOGOS: Record = { + HEZ: '/tokens/HEZ.png', + PEZ: '/tokens/PEZ.png', + USDT: '/tokens/USDT.png', + wUSDT: '/tokens/USDT.png', + BNB: '/tokens/BNB.png', + BTC: '/tokens/BTC.png', + DOT: '/tokens/DOT.png', + ETH: '/tokens/ETH.png', + }; + + // Get token logo URL + const getTokenLogo = (symbol: string): string | null => { + return TOKEN_LOGOS[symbol] || TOKEN_LOGOS[symbol.toUpperCase()] || null; + }; + + // Get token color based on assetId + const getTokenColor = (assetId: number) => { + const colors = { + [ASSET_IDS.WHEZ]: { bg: 'from-green-500/20 to-yellow-500/20', text: 'text-green-400', border: 'border-green-500/30' }, + [ASSET_IDS.WUSDT]: { bg: 'from-emerald-500/20 to-teal-500/20', text: 'text-emerald-400', border: 'border-emerald-500/30' }, + }; + return colors[assetId] || { bg: 'from-cyan-500/20 to-blue-500/20', text: 'text-cyan-400', border: 'border-cyan-500/30' }; + }; + + // Fetch token prices from pools using pool account ID + const fetchTokenPrices = async () => { + if (!api || !isApiReady) return; + + try { + if (process.env.NODE_ENV !== 'production') console.log('💰 Fetching token prices from pools...'); + + // Import utilities for pool account derivation + const { stringToU8a } = await import('@pezkuwi/util'); + const { blake2AsU8a } = await import('@pezkuwi/util-crypto'); + const PALLET_ID = stringToU8a('py/ascon'); + + // Fetch wHEZ/wUSDT pool reserves (Asset 0 / Asset 1000) + const whezPoolId = api.createType('(u32, u32)', [0, ASSET_IDS.WUSDT]); + const whezPalletIdType = api.createType('[u8; 8]', PALLET_ID); + const whezFullTuple = api.createType('([u8; 8], (u32, u32))', [whezPalletIdType, whezPoolId]); + const whezAccountHash = blake2AsU8a(whezFullTuple.toU8a(), 256); + const whezPoolAccountId = api.createType('AccountId32', whezAccountHash); + + const whezReserve0Query = await api.query.assets.account(0, whezPoolAccountId); + const whezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, whezPoolAccountId); + + if (whezReserve0Query.isSome && whezReserve1Query.isSome) { + const reserve0Data = whezReserve0Query.unwrap(); + const reserve1Data = whezReserve1Query.unwrap(); + + const reserve0 = BigInt(reserve0Data.balance.toString()); // wHEZ (12 decimals) + const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals) + + // Calculate price: 1 HEZ = ? USD + const hezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6)); + if (process.env.NODE_ENV !== 'production') console.log('✅ HEZ price:', hezPrice, 'USD'); + setHezUsdPrice(hezPrice); + } else { + if (process.env.NODE_ENV !== 'production') console.warn('⚠️ wHEZ/wUSDT pool has no reserves'); + } + + // Fetch PEZ/wUSDT pool reserves (Asset 1 / Asset 1000) + const pezPoolId = api.createType('(u32, u32)', [1, ASSET_IDS.WUSDT]); + const pezPalletIdType = api.createType('[u8; 8]', PALLET_ID); + const pezFullTuple = api.createType('([u8; 8], (u32, u32))', [pezPalletIdType, pezPoolId]); + const pezAccountHash = blake2AsU8a(pezFullTuple.toU8a(), 256); + const pezPoolAccountId = api.createType('AccountId32', pezAccountHash); + + const pezReserve0Query = await api.query.assets.account(1, pezPoolAccountId); + const pezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, pezPoolAccountId); + + if (pezReserve0Query.isSome && pezReserve1Query.isSome) { + const reserve0Data = pezReserve0Query.unwrap(); + const reserve1Data = pezReserve1Query.unwrap(); + + const reserve0 = BigInt(reserve0Data.balance.toString()); // PEZ (12 decimals) + const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals) + + // Calculate price: 1 PEZ = ? USD + const pezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6)); + if (process.env.NODE_ENV !== 'production') console.log('✅ PEZ price:', pezPrice, 'USD'); + setPezUsdPrice(pezPrice); + } else { + if (process.env.NODE_ENV !== 'production') console.warn('⚠️ PEZ/wUSDT pool has no reserves'); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('❌ Failed to fetch token prices:', error); + } + }; + + // Fetch other tokens (only custom tokens - wrapped tokens are backend-only) + const fetchOtherTokens = async () => { + if (!api || !isApiReady || !selectedAccount) return; + + try { + const tokens: TokenBalance[] = []; + + // IMPORTANT: Only show custom tokens added by user + // Wrapped tokens (wHEZ, wUSDT) are for backend operations only + // Core tokens (HEZ, PEZ) are shown in their own dedicated cards + const assetIdsToCheck = customTokenIds.filter((id) => + id !== ASSET_IDS.WHEZ && // Exclude wrapped tokens + id !== ASSET_IDS.WUSDT && + id !== ASSET_IDS.PEZ // Exclude core tokens + ); + + for (const assetId of assetIdsToCheck) { + try { + const assetBalance = await api.query.assets.account(assetId, selectedAccount.address); + const assetMetadata = await api.query.assets.metadata(assetId); + + if (assetBalance.isSome) { + const assetData = assetBalance.unwrap(); + const balance = assetData.balance.toString(); + + const metadata = assetMetadata.toJSON() as { symbol?: string; name?: string; decimals?: number }; + + // Decode hex strings properly + let symbol = metadata.symbol || ''; + let name = metadata.name || ''; + + if (typeof symbol === 'string' && symbol.startsWith('0x')) { + symbol = hexToString(symbol); + } + if (typeof name === 'string' && name.startsWith('0x')) { + name = hexToString(name); + } + + // Fallback to known symbols if metadata is empty + if (!symbol || symbol.trim() === '') { + symbol = getAssetSymbol(assetId); + } + if (!name || name.trim() === '') { + name = symbol; + } + + const decimals = metadata.decimals || getAssetDecimals(assetId); + const balanceFormatted = (parseInt(balance) / Math.pow(10, decimals)).toFixed(6); + + // Simple USD calculation (would use real price feed in production) + let usdValue = 0; + if (assetId === ASSET_IDS.WUSDT) { + usdValue = parseFloat(balanceFormatted); // 1 wUSDT = 1 USD + } else if (assetId === ASSET_IDS.WHEZ) { + usdValue = parseFloat(balanceFormatted) * 0.5; // Placeholder price + } + + tokens.push({ + assetId, + symbol: symbol.trim(), + name: name.trim(), + balance: balanceFormatted, + decimals, + usdValue + }); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error(`Failed to fetch token ${assetId}:`, error); + } + } + + setOtherTokens(tokens); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch other tokens:', error); + } + }; + + const fetchBalance = async () => { + if (!api || !isApiReady || !selectedAccount) return; + + setIsLoading(true); + try { + // Fetch HEZ balance + const { data: balanceData } = await api.query.system.account(selectedAccount.address); + + const free = balanceData.free.toString(); + const reserved = balanceData.reserved.toString(); + + // Convert from plancks to tokens (12 decimals) + const decimals = 12; + const divisor = Math.pow(10, decimals); + + const freeTokens = (parseInt(free) / divisor).toFixed(4); + const reservedTokens = (parseInt(reserved) / divisor).toFixed(4); + const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4); + + setBalance({ + free: freeTokens, + reserved: reservedTokens, + total: totalTokens, + }); + + // Fetch PEZ balance (Asset ID: 1) + try { + const pezAssetBalance = await api.query.assets.account(1, selectedAccount.address); + + if (pezAssetBalance.isSome) { + const assetData = pezAssetBalance.unwrap(); + const pezAmount = assetData.balance.toString(); + const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4); + setPezBalance(pezTokens); + } else { + setPezBalance('0'); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch PEZ balance:', error); + setPezBalance('0'); + } + + // Fetch USDT balance (wUSDT - Asset ID: 1000) + try { + const usdtAssetBalance = await api.query.assets.account(ASSET_IDS.WUSDT, selectedAccount.address); + + if (usdtAssetBalance.isSome) { + const assetData = usdtAssetBalance.unwrap(); + const usdtAmount = assetData.balance.toString(); + const usdtDecimals = 6; // wUSDT has 6 decimals + const usdtDivisor = Math.pow(10, usdtDecimals); + const usdtTokens = (parseInt(usdtAmount) / usdtDivisor).toFixed(2); + setUsdtBalance(usdtTokens); + } else { + setUsdtBalance('0'); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch USDT balance:', error); + setUsdtBalance('0'); + } + + // Fetch token prices from pools + await fetchTokenPrices(); + + // Fetch other tokens + await fetchOtherTokens(); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch balance:', error); + } finally { + setIsLoading(false); + } + }; + + // Add custom token handler + const handleAddToken = async (assetId: number) => { + if (customTokenIds.includes(assetId)) { + alert('Token already added!'); + return; + } + + // Update custom tokens list + const updatedTokenIds = [...customTokenIds, assetId]; + setCustomTokenIds(updatedTokenIds); + localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds)); + + // Fetch the new token + await fetchOtherTokens(); + setIsAddTokenModalOpen(false); + }; + + // Remove token handler (unused but kept for future feature) + // const handleRemoveToken = (assetId: number) => { + // const updatedTokenIds = customTokenIds.filter(id => id !== assetId); + // setCustomTokenIds(updatedTokenIds); + // localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds)); + // + // // Remove from displayed tokens + // setOtherTokens(prev => prev.filter(t => t.assetId !== assetId)); + // }; + + useEffect(() => { + fetchBalance(); + fetchTokenPrices(); // Fetch token USD prices from pools + + // Fetch All Scores from blockchain + const fetchAllScores = async () => { + if (!api || !isApiReady || !selectedAccount?.address) { + setScores({ + trustScore: 0, + referralScore: 0, + stakingScore: 0, + tikiScore: 0, + totalScore: 0 + }); + return; + } + + setLoadingScores(true); + try { + const userScores = await getAllScores(api, selectedAccount.address); + setScores(userScores); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch scores:', err); + setScores({ + trustScore: 0, + referralScore: 0, + stakingScore: 0, + tikiScore: 0, + totalScore: 0 + }); + } finally { + setLoadingScores(false); + } + }; + + fetchAllScores(); + + // Subscribe to HEZ balance updates + let unsubscribeHez: () => void; + let unsubscribePez: () => void; + let unsubscribeUsdt: () => void; + + const subscribeBalance = async () => { + if (!api || !isApiReady || !selectedAccount) return; + + // Subscribe to HEZ balance + unsubscribeHez = await api.query.system.account( + selectedAccount.address, + ({ data: balanceData }) => { + const free = balanceData.free.toString(); + const reserved = balanceData.reserved.toString(); + + const decimals = 12; + const divisor = Math.pow(10, decimals); + + const freeTokens = (parseInt(free) / divisor).toFixed(4); + const reservedTokens = (parseInt(reserved) / divisor).toFixed(4); + const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4); + + setBalance({ + free: freeTokens, + reserved: reservedTokens, + total: totalTokens, + }); + } + ); + + // Subscribe to PEZ balance (Asset ID: 1) + try { + unsubscribePez = await api.query.assets.account( + 1, + selectedAccount.address, + (assetBalance) => { + if (assetBalance.isSome) { + const assetData = assetBalance.unwrap(); + const pezAmount = assetData.balance.toString(); + const decimals = 12; + const divisor = Math.pow(10, decimals); + const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4); + setPezBalance(pezTokens); + } else { + setPezBalance('0'); + } + } + ); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to PEZ balance:', error); + } + + // Subscribe to USDT balance (wUSDT - Asset ID: 2) + try { + unsubscribeUsdt = await api.query.assets.account( + 2, + selectedAccount.address, + (assetBalance) => { + if (assetBalance.isSome) { + const assetData = assetBalance.unwrap(); + const usdtAmount = assetData.balance.toString(); + const decimals = 6; // wUSDT has 6 decimals + const divisor = Math.pow(10, decimals); + const usdtTokens = (parseInt(usdtAmount) / divisor).toFixed(2); + setUsdtBalance(usdtTokens); + } else { + setUsdtBalance('0'); + } + } + ); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to USDT balance:', error); + } + }; + + subscribeBalance(); + + return () => { + if (unsubscribeHez) unsubscribeHez(); + if (unsubscribePez) unsubscribePez(); + if (unsubscribeUsdt) unsubscribeUsdt(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, isApiReady, selectedAccount]); + + if (!selectedAccount) { + return ( + + +
+ +

Connect your wallet to view balance

+
+
+
+ ); + } + + return ( +
+ {/* HEZ Balance Card */} + + +
+
+ HEZ + + HEZ Balance + +
+ +
+
+ +
+
+
+ {isLoading ? '...' : balance.total} + HEZ +
+
+ {hezUsdPrice > 0 + ? `≈ $${(parseFloat(balance.total) * hezUsdPrice).toFixed(2)} USD` + : 'Price loading...'} +
+
+ +
+
+
+ + Transferable +
+
+ {balance.free} HEZ +
+
+ +
+
+ + Reserved +
+
+ {balance.reserved} HEZ +
+
+
+
+
+
+ + {/* PEZ Balance Card */} + + +
+ PEZ + + PEZ Token Balance + +
+
+ +
+
+ {isLoading ? '...' : pezBalance} + PEZ +
+
+ {pezUsdPrice > 0 + ? `≈ $${(parseFloat(pezBalance) * pezUsdPrice).toFixed(2)} USD` + : 'Price loading...'} +
+
+ Governance & Rewards Token +
+
+
+
+ + {/* USDT Balance Card */} + + +
+ USDT + + USDT Balance + +
+
+ +
+
+ {isLoading ? '...' : usdtBalance} + USDT +
+
+ ≈ ${usdtBalance} USD • Stablecoin +
+
+
+
+ + {/* Account Info & Scores */} + + + + Account Information + + + +
+ {/* Account Details */} +
+
+ Account + + {selectedAccount.meta.name || 'Unnamed'} + +
+
+ Address + + {selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-8)} + +
+
+ + {/* Scores from Blockchain */} +
+
Scores from Blockchain
+ {loadingScores ? ( +
Loading scores...
+ ) : ( +
+ {/* Score Grid */} +
+
+
+ + Trust +
+ {scores.trustScore} +
+
+
+ + Referral +
+ {scores.referralScore} +
+
+
+ + Staking +
+ {scores.stakingScore} +
+
+
+ + Tiki +
+ {scores.tikiScore} +
+
+ + {/* Total Score */} +
+
+ Total Score + + {scores.totalScore} + +
+
+
+ )} +
+
+
+
+ + {/* Other Tokens */} + + +
+
+ + + Other Assets + +
+ +
+
+ + {otherTokens.length === 0 ? ( +
+ +

No custom tokens yet

+

+ Add custom tokens to track additional assets +

+
+ ) : ( +
+ {otherTokens.map((token) => { + const tokenColor = getTokenColor(token.assetId); + return ( +
+
+ {/* Token Logo */} + {getTokenLogo(token.symbol) ? ( + {token.symbol} + ) : ( +
+ + {token.symbol.slice(0, 2).toUpperCase()} + +
+ )} + + {/* Token Info */} +
+
+ + {token.symbol} + + + #{token.assetId} + +
+
+ {token.name} +
+
+
+ + {/* Balance & Actions */} +
+
+
+ {parseFloat(token.balance).toFixed(4)} +
+
+ ${token.usdValue.toFixed(2)} USD +
+
+ + {/* Send Button */} + +
+
+ ); + })} +
+ )} +
+
+ + {/* Add Token Modal */} + setIsAddTokenModalOpen(false)} + onAddToken={handleAddToken} + /> + + {/* Transfer Modal */} + { + setIsTransferModalOpen(false); + setSelectedTokenForTransfer(null); + }} + selectedAsset={selectedTokenForTransfer} + /> +
+ ); +}; diff --git a/packages/apps/src/components/AddLiquidityModal.tsx b/packages/apps/src/components/AddLiquidityModal.tsx new file mode 100644 index 0000000..3aa3da1 --- /dev/null +++ b/packages/apps/src/components/AddLiquidityModal.tsx @@ -0,0 +1,550 @@ +import React, { useState, useEffect } from 'react'; +import { X, Plus, Info, AlertCircle } from 'lucide-react'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; + +interface AddLiquidityModalProps { + isOpen: boolean; + onClose: () => void; + asset0?: number; // Pool's first asset ID + asset1?: number; // Pool's second asset ID +} + +interface AssetDetails { + minBalance?: string | number; +} + +interface AssetAccountData { + balance: string | number; +} + +interface Balances { + [key: string]: number; +} + +// Helper to get display name (users see HEZ not wHEZ, PEZ, USDT not wUSDT) +const getDisplayName = (assetId: number): string => { + if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ'; + if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; + if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; + return getAssetSymbol(assetId); +}; + +// Helper to get balance key for the asset +const getBalanceKey = (assetId: number): string => { + if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ'; + if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; + if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; + return getAssetSymbol(assetId); +}; + +// Helper to get decimals for asset +const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ have 12 decimals +}; + +export const AddLiquidityModal: React.FC = ({ + isOpen, + onClose, + asset0 = 0, // Default to wHEZ + asset1 = 1 // Default to PEZ +}) => { + const { api, selectedAccount, isApiReady } = usePezkuwi(); + const { balances, refreshBalances } = useWallet(); + + const [amount0, setAmount0] = useState(''); + const [amount1, setAmount1] = useState(''); + const [currentPrice, setCurrentPrice] = useState(null); + const [isPoolEmpty, setIsPoolEmpty] = useState(true); // Track if pool has meaningful liquidity + const [minDeposit0, setMinDeposit0] = useState(0.01); // Dynamic minimum deposit for asset0 + const [minDeposit1, setMinDeposit1] = useState(0.01); // Dynamic minimum deposit for asset1 + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Get asset details + const asset0Name = getDisplayName(asset0); + const asset1Name = getDisplayName(asset1); + const asset0BalanceKey = getBalanceKey(asset0); + const asset1BalanceKey = getBalanceKey(asset1); + const asset0Decimals = getAssetDecimals(asset0); + const asset1Decimals = getAssetDecimals(asset1); + + // Reset form when modal is closed + useEffect(() => { + if (!isOpen) { + setAmount0(''); + setAmount1(''); + setError(null); + setSuccess(false); + } + }, [isOpen]); + + // Fetch minimum deposit requirements from runtime + useEffect(() => { + if (!api || !isApiReady || !isOpen) return; + + const fetchMinimumBalances = async () => { + try { + // Query asset details which contains minBalance + const assetDetails0 = await api.query.assets.asset(asset0); + const assetDetails1 = await api.query.assets.asset(asset1); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Querying minimum balances for assets:', { asset0, asset1 }); + + if (assetDetails0.isSome && assetDetails1.isSome) { + const details0 = assetDetails0.unwrap().toJSON() as AssetDetails; + const details1 = assetDetails1.unwrap().toJSON() as AssetDetails; + + if (process.env.NODE_ENV !== 'production') console.log('📦 Asset details:', { + asset0: details0, + asset1: details1 + }); + + const minBalance0Raw = details0.minBalance || '0'; + const minBalance1Raw = details1.minBalance || '0'; + + const minBalance0 = Number(minBalance0Raw) / Math.pow(10, asset0Decimals); + const minBalance1 = Number(minBalance1Raw) / Math.pow(10, asset1Decimals); + + if (process.env.NODE_ENV !== 'production') console.log('📊 Minimum deposit requirements from assets:', { + asset0: asset0Name, + minBalance0Raw, + minBalance0, + asset1: asset1Name, + minBalance1Raw, + minBalance1 + }); + + setMinDeposit0(minBalance0); + setMinDeposit1(minBalance1); + } else { + if (process.env.NODE_ENV !== 'production') console.warn('⚠️ Asset details not found, using defaults'); + } + + // Also check if there's a MintMinLiquidity constant in assetConversion pallet + if (api.consts.assetConversion) { + const mintMinLiq = api.consts.assetConversion.mintMinLiquidity; + if (mintMinLiq) { + if (process.env.NODE_ENV !== 'production') console.log('🔧 AssetConversion MintMinLiquidity constant:', mintMinLiq.toString()); + } + + const liquidityWithdrawalFee = api.consts.assetConversion.liquidityWithdrawalFee; + if (liquidityWithdrawalFee) { + if (process.env.NODE_ENV !== 'production') console.log('🔧 AssetConversion LiquidityWithdrawalFee:', liquidityWithdrawalFee.toHuman()); + } + + // Log all assetConversion constants + if (process.env.NODE_ENV !== 'production') console.log('🔧 All assetConversion constants:', Object.keys(api.consts.assetConversion)); + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('❌ Error fetching minimum balances:', err); + // Keep default 0.01 if query fails + } + }; + + fetchMinimumBalances(); + }, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals, asset0Name, asset1Name]); + + // Fetch current pool price + useEffect(() => { + if (!api || !isApiReady || !isOpen) return; + + const fetchPoolPrice = async () => { + try { + const poolId = [asset0, asset1]; + const poolInfo = await api.query.assetConversion.pools(poolId); + + if (poolInfo.isSome) { + // Derive pool account using AccountIdConverter + const { stringToU8a } = await import('@pezkuwi/util'); + const { blake2AsU8a } = await import('@pezkuwi/util-crypto'); + + const PALLET_ID = stringToU8a('py/ascon'); + const poolIdType = api.createType('(u32, u32)', [asset0, asset1]); + const palletIdType = api.createType('[u8; 8]', PALLET_ID); + const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolIdType]); + + const accountHash = blake2AsU8a(fullTuple.toU8a(), 256); + const poolAccountId = api.createType('AccountId32', accountHash); + + // Get reserves + const balance0Data = await api.query.assets.account(asset0, poolAccountId); + const balance1Data = await api.query.assets.account(asset1, poolAccountId); + + if (balance0Data.isSome && balance1Data.isSome) { + const data0 = balance0Data.unwrap().toJSON() as AssetAccountData; + const data1 = balance1Data.unwrap().toJSON() as AssetAccountData; + + const reserve0 = Number(data0.balance) / Math.pow(10, asset0Decimals); + const reserve1 = Number(data1.balance) / Math.pow(10, asset1Decimals); + + // Consider pool empty if reserves are less than 1 token (dust amounts) + const MINIMUM_LIQUIDITY = 1; + if (reserve0 >= MINIMUM_LIQUIDITY && reserve1 >= MINIMUM_LIQUIDITY) { + setCurrentPrice(reserve1 / reserve0); + setIsPoolEmpty(false); + if (process.env.NODE_ENV !== 'production') console.log('Pool has liquidity - auto-calculating ratio:', reserve1 / reserve0); + } else { + setCurrentPrice(null); + setIsPoolEmpty(true); + if (process.env.NODE_ENV !== 'production') console.log('Pool is empty or has dust only - manual input allowed'); + } + } else { + // No reserves found - pool is empty + setCurrentPrice(null); + setIsPoolEmpty(true); + if (process.env.NODE_ENV !== 'production') console.log('Pool is empty - manual input allowed'); + } + } else { + // Pool doesn't exist yet - completely empty + setCurrentPrice(null); + setIsPoolEmpty(true); + if (process.env.NODE_ENV !== 'production') console.log('Pool does not exist yet - manual input allowed'); + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching pool price:', err); + // On error, assume pool is empty to allow manual input + setCurrentPrice(null); + setIsPoolEmpty(true); + } + }; + + fetchPoolPrice(); + }, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals]); + + // Auto-calculate asset1 amount based on asset0 input (only if pool has liquidity) + useEffect(() => { + if (!isPoolEmpty && amount0 && currentPrice) { + const calculated = parseFloat(amount0) * currentPrice; + setAmount1(calculated.toFixed(asset1Decimals === 6 ? 2 : 4)); + } else if (!amount0 && !isPoolEmpty) { + setAmount1(''); + } + // If pool is empty, don't auto-calculate - let user input both amounts + }, [amount0, currentPrice, asset1Decimals, isPoolEmpty]); + + const handleAddLiquidity = async () => { + if (!api || !selectedAccount || !amount0 || !amount1) return; + + setIsLoading(true); + setError(null); + + try { + // Validate amounts + if (parseFloat(amount0) <= 0 || parseFloat(amount1) <= 0) { + setError('Please enter valid amounts'); + setIsLoading(false); + return; + } + + // Check minimum deposit requirements from runtime + if (parseFloat(amount0) < minDeposit0) { + setError(`${asset0Name} amount must be at least ${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`); + setIsLoading(false); + return; + } + + if (parseFloat(amount1) < minDeposit1) { + setError(`${asset1Name} amount must be at least ${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`); + setIsLoading(false); + return; + } + + const balance0 = (balances as Balances)[asset0BalanceKey] || 0; + const balance1 = (balances as Balances)[asset1BalanceKey] || 0; + + if (parseFloat(amount0) > balance0) { + setError(`Insufficient ${asset0Name} balance`); + setIsLoading(false); + return; + } + + if (parseFloat(amount1) > balance1) { + setError(`Insufficient ${asset1Name} balance`); + setIsLoading(false); + return; + } + + // Get the signer from the extension + const injector = await web3FromAddress(selectedAccount.address); + + // Convert amounts to proper decimals + const amount0BN = BigInt(Math.floor(parseFloat(amount0) * Math.pow(10, asset0Decimals))); + const amount1BN = BigInt(Math.floor(parseFloat(amount1) * Math.pow(10, asset1Decimals))); + + // Min amounts (90% of desired to account for slippage) + const minAmount0BN = (amount0BN * BigInt(90)) / BigInt(100); + const minAmount1BN = (amount1BN * BigInt(90)) / BigInt(100); + + // Build transaction(s) + let tx; + + // If asset0 is HEZ (0), need to wrap it first + if (asset0 === 0 || asset0 === ASSET_IDS.WHEZ) { + const wrapTx = api.tx.tokenWrapper.wrap(amount0BN.toString()); + + const addLiquidityTx = api.tx.assetConversion.addLiquidity( + asset0, + asset1, + amount0BN.toString(), + amount1BN.toString(), + minAmount0BN.toString(), + minAmount1BN.toString(), + selectedAccount.address + ); + + // Batch wrap + add liquidity + tx = api.tx.utility.batchAll([wrapTx, addLiquidityTx]); + } else { + // Direct add liquidity (no wrapping needed) + tx = api.tx.assetConversion.addLiquidity( + asset0, + asset1, + amount0BN.toString(), + amount1BN.toString(), + minAmount0BN.toString(), + minAmount1BN.toString(), + selectedAccount.address + ); + } + + await tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, events, dispatchError }) => { + if (status.isInBlock) { + if (process.env.NODE_ENV !== 'production') console.log('Transaction in block:', status.asInBlock.toHex()); + } else if (status.isFinalized) { + if (process.env.NODE_ENV !== 'production') console.log('Transaction finalized:', status.asFinalized.toHex()); + + // Check for errors + const hasError = events.some(({ event }) => + api.events.system.ExtrinsicFailed.is(event) + ); + + if (hasError || dispatchError) { + let errorMessage = 'Transaction failed'; + + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + const { docs, name, section } = decoded; + errorMessage = `${section}.${name}: ${docs.join(' ')}`; + if (process.env.NODE_ENV !== 'production') console.error('Dispatch error:', errorMessage); + } else { + errorMessage = dispatchError.toString(); + if (process.env.NODE_ENV !== 'production') console.error('Dispatch error:', errorMessage); + } + } + + events.forEach(({ event }) => { + if (api.events.system.ExtrinsicFailed.is(event)) { + if (process.env.NODE_ENV !== 'production') console.error('ExtrinsicFailed event:', event.toHuman()); + } + }); + + setError(errorMessage); + setIsLoading(false); + } else { + if (process.env.NODE_ENV !== 'production') console.log('Transaction successful'); + setSuccess(true); + setIsLoading(false); + setAmount0(''); + setAmount1(''); + refreshBalances(); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } + } + } + ); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error adding liquidity:', err); + setError(err instanceof Error ? err.message : 'Failed to add liquidity'); + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + const balance0 = (balances as Balances)[asset0BalanceKey] || 0; + const balance1 = (balances as Balances)[asset1BalanceKey] || 0; + + return ( +
+
+
+

Add Liquidity

+ +
+ + {error && ( + + + {error} + + )} + + {success && ( + + Liquidity added successfully! + + )} + + {isPoolEmpty ? ( + + + + First Liquidity Provider: Pool is empty! You are setting the initial price ratio. + Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}. + {(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'} + + + ) : ( + + + + Add liquidity to earn 3% fees from all swaps. Amounts are auto-calculated based on current pool ratio. + Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}. + {(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'} + + + )} + +
+ {/* Asset 0 Input */} +
+ +
+ setAmount0(e.target.value)} + placeholder={`${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} or more`} + min={minDeposit0} + step={minDeposit0 < 1 ? minDeposit0 : 0.01} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500" + disabled={isLoading} + /> +
+ {asset0Name} +
+
+
+ Balance: {balance0.toLocaleString()} + +
+
+ +
+ +
+ + {/* Asset 1 Input */} +
+ +
+ setAmount1(e.target.value)} + placeholder={isPoolEmpty ? `${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} or more` : "Auto-calculated"} + min={isPoolEmpty ? minDeposit1 : undefined} + step={isPoolEmpty ? (minDeposit1 < 1 ? minDeposit1 : 0.01) : undefined} + className={`w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 focus:outline-none ${ + isPoolEmpty + ? 'text-white focus:border-blue-500' + : 'text-gray-400 cursor-not-allowed' + }`} + disabled={!isPoolEmpty || isLoading} + readOnly={!isPoolEmpty} + /> +
+ {asset1Name} +
+
+
+ Balance: {balance1.toLocaleString()} + {isPoolEmpty ? ( + + ) : ( + currentPrice && Rate: 1 {asset0Name} = {currentPrice.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name} + )} +
+
+ + {/* Price Info */} + {amount0 && amount1 && ( +
+ {isPoolEmpty && ( +
+ Initial Price + + 1 {asset0Name} = {(parseFloat(amount1) / parseFloat(amount0)).toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name} + +
+ )} +
+ Share of Pool + {isPoolEmpty ? '100%' : '~0.1%'} +
+
+ Slippage Tolerance + 10% +
+
+ )} + + +
+
+
+ ); +}; diff --git a/packages/apps/src/components/AddTokenModal.tsx b/packages/apps/src/components/AddTokenModal.tsx new file mode 100644 index 0000000..940d117 --- /dev/null +++ b/packages/apps/src/components/AddTokenModal.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { AlertCircle } from 'lucide-react'; + +interface AddTokenModalProps { + isOpen: boolean; + onClose: () => void; + onAddToken: (assetId: number) => Promise; +} + +export const AddTokenModal: React.FC = ({ + isOpen, + onClose, + onAddToken, +}) => { + const [assetId, setAssetId] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const id = parseInt(assetId); + if (isNaN(id) || id < 0) { + setError('Please enter a valid asset ID (number)'); + return; + } + + setIsLoading(true); + try { + await onAddToken(id); + setAssetId(''); + setError(''); + } catch { + setError('Failed to add token. Please check the asset ID and try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setAssetId(''); + setError(''); + onClose(); + }; + + return ( + + + + Add Custom Token + + Enter the asset ID of the token you want to track. + Note: Core tokens (HEZ, PEZ) are already displayed separately. + + + +
+
+ + setAssetId(e.target.value)} + placeholder="e.g., 3" + className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500" + min="0" + required + /> +

+ Each token on the network has a unique asset ID +

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/apps/src/components/AppLayout.tsx b/packages/apps/src/components/AppLayout.tsx new file mode 100644 index 0000000..fb3df28 --- /dev/null +++ b/packages/apps/src/components/AppLayout.tsx @@ -0,0 +1,582 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '@/contexts/AuthContext'; +import HeroSection from './HeroSection'; +import TokenomicsSection from './TokenomicsSection'; +import PalletsGrid from './PalletsGrid'; +import ChainSpecs from './ChainSpecs'; +import TrustScoreCalculator from './TrustScoreCalculator'; +import { NetworkStats } from './NetworkStats'; +import { WalletModal } from './wallet/WalletModal'; +import { LanguageSwitcher } from './LanguageSwitcher'; +import NotificationBell from './notifications/NotificationBell'; +import ProposalWizard from './proposals/ProposalWizard'; +import DelegationManager from './delegation/DelegationManager'; +import { ForumOverview } from './forum/ForumOverview'; +import { ModerationPanel } from './forum/ModerationPanel'; +import { TreasuryOverview } from './treasury/TreasuryOverview'; +import { FundingProposal } from './treasury/FundingProposal'; +import { SpendingHistory } from './treasury/SpendingHistory'; +import { MultiSigApproval } from './treasury/MultiSigApproval'; +import { ExternalLink, Award, FileEdit, Users2, MessageSquare, ShieldCheck, Wifi, WifiOff, Wallet, DollarSign, PiggyBank, History, Key, TrendingUp, ArrowRightLeft, Lock, LogIn, LayoutDashboard, Settings, Users, Droplet, Mail, Coins } from 'lucide-react'; +import GovernanceInterface from './GovernanceInterface'; +import RewardDistribution from './RewardDistribution'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useWebSocket } from '@/contexts/WebSocketContext'; +import { StakingDashboard } from './staking/StakingDashboard'; +import { MultiSigWallet } from './wallet/MultiSigWallet'; +import { useWallet } from '@/contexts/WalletContext'; +import { supabase } from '@/lib/supabase'; +import { PezkuwiWalletButton } from './PezkuwiWalletButton'; +import { DEXDashboard } from './dex/DEXDashboard'; +import { P2PDashboard } from './p2p/P2PDashboard'; +import EducationPlatform from '../pages/EducationPlatform'; + +const AppLayout: React.FC = () => { + const navigate = useNavigate(); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const { user, signOut } = useAuth(); + const [showProposalWizard, setShowProposalWizard] = useState(false); + const [showDelegation, setShowDelegation] = useState(false); + const [showForum, setShowForum] = useState(false); + const [showModeration, setShowModeration] = useState(false); + const [showTreasury, setShowTreasury] = useState(false); + const [treasuryTab, setTreasuryTab] = useState('overview'); + const [showStaking, setShowStaking] = useState(false); + const [showMultiSig, setShowMultiSig] = useState(false); + const [showDEX, setShowDEX] = useState(false); + const [showEducation, setShowEducation] = useState(false); + const [showP2P, setShowP2P] = useState(false); + const { t } = useTranslation(); + const { isConnected } = useWebSocket(); + useWallet(); + const [, _setIsAdmin] = useState(false); + + // Check if user is admin + React.useEffect(() => { + const checkAdminStatus = async () => { + if (user) { + const { data, error } = await supabase + .from('admin_roles') + .select('role') + .eq('user_id', user.id) + .maybeSingle(); + + if (error) { + if (process.env.NODE_ENV !== 'production') console.warn('Admin check error:', error); + } + _setIsAdmin(!!data); + } else { + _setIsAdmin(false); + } + }; + checkAdminStatus(); + }, [user]); + return ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+ {/* Conditional Rendering for Features */} + {showDEX ? ( +
+
+ +
+
+ ) : showProposalWizard ? ( +
+
+ { + if (process.env.NODE_ENV !== 'production') console.log('Proposal created:', proposal); + setShowProposalWizard(false); + }} + onCancel={() => setShowProposalWizard(false)} + /> +
+
+ ) : showDelegation ? ( +
+
+ +
+
+ ) : showForum ? ( +
+
+ +
+
+ ) : showModeration ? ( +
+
+ +
+
+ ) : showTreasury ? ( +
+
+
+

+ {t('treasury.title', 'Treasury Management')} +

+

+ {t('treasury.subtitle', 'Track funds, submit proposals, and manage community resources')} +

+
+ + + + + + {t('treasury.overview', 'Overview')} + + + + {t('treasury.proposals', 'Funding Proposals')} + + + + {t('treasury.history', 'Spending History')} + + + + {t('treasury.approvals', 'Multi-Sig Approvals')} + + + + + + + + + + + + + + + + + + + +
+
+ ) : showStaking ? ( +
+
+
+

+ Staking Rewards +

+

+ Stake your tokens and earn rewards +

+
+ +
+
+ ) : showMultiSig ? ( +
+
+
+

+ Multi-Signature Wallet +

+

+ Secure your funds with multi-signature protection +

+
+ +
+
+ ) : showEducation ? ( +
+ +
+ ) : showP2P ? ( +
+ +
+ ) : ( + <> + + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+ + )} + + + {(showDEX || showProposalWizard || showDelegation || showForum || showModeration || showTreasury || showStaking || showMultiSig || showEducation || showP2P) && ( +
+ +
+ )} +
+ + {/* Wallet Modal */} + setWalletModalOpen(false)} /> + + {/* Footer */} + +
+ ); +}; + +export default AppLayout; \ No newline at end of file diff --git a/packages/apps/src/components/ChainSpecs.tsx b/packages/apps/src/components/ChainSpecs.tsx new file mode 100644 index 0000000..23c643e --- /dev/null +++ b/packages/apps/src/components/ChainSpecs.tsx @@ -0,0 +1,269 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Server, Globe, TestTube, Code, Wifi, Copy, Check, ExternalLink, Compass, Book, Briefcase, FileCode, HandCoins, Users, Wrench, MessageCircle, GitFork } from 'lucide-react'; + +// ... (interface and const arrays remain the same) ... + +interface ChainSpec { + id: string; + name: string; + type: 'Live' | 'Development' | 'Local'; + icon: React.ReactNode; + endpoint: string; + chainId: string; + validators: number; + features: string[]; + color: string; +} + +const chainSpecs: ChainSpec[] = [ + { + id: 'mainnet', + name: 'PezkuwiChain Mainnet', + type: 'Live', + icon: , + endpoint: 'wss://mainnet.pezkuwichain.io', + chainId: '0x1234...abcd', + validators: 100, + features: ['Production', 'Real Tokenomics', 'Full Security'], + color: 'from-purple-500 to-purple-600' + }, + { + id: 'staging', + name: 'PezkuwiChain Staging', + type: 'Live', + icon: , + endpoint: 'wss://staging.pezkuwichain.io', + chainId: '0x5678...efgh', + validators: 20, + features: ['Pre-production', 'Testing Features', 'Beta Access'], + color: 'from-cyan-500 to-cyan-600' + }, + { + id: 'testnet', + name: 'Real Testnet', + type: 'Live', + icon: , + endpoint: 'wss://testnet.pezkuwichain.io', + chainId: '0x9abc...ijkl', + validators: 8, + features: ['Test Tokens', 'Full Features', 'Public Testing'], + color: 'from-teal-500 to-teal-600' + }, + { + id: 'beta', + name: 'Beta Testnet', + type: 'Live', + icon: , + endpoint: 'wss://beta.pezkuwichain.io', + chainId: '0xdef0...mnop', + validators: 4, + features: ['Experimental', 'New Features', 'Limited Access'], + color: 'from-orange-500 to-orange-600' + }, + { + id: 'alfa', + name: 'PezkuwiChain Alfa Testnet', + type: 'Development', + icon: , + endpoint: 'ws://127.0.0.1:8844', + chainId: 'pezkuwichain_alfa_testnet', + validators: 4, + features: ['4 Validators', 'Staking Active', 'Full Features'], + color: 'from-purple-500 to-pink-600' + }, + { + id: 'development', + name: 'Development', + type: 'Development', + icon: , + endpoint: 'ws://127.0.0.1:9944', + chainId: '0xlocal...dev', + validators: 1, + features: ['Single Node', 'Fast Block Time', 'Dev Tools'], + color: 'from-green-500 to-green-600' + }, + { + id: 'local', + name: 'Local Testnet', + type: 'Local', + icon: , + endpoint: 'ws://127.0.0.1:9945', + chainId: '0xlocal...test', + validators: 2, + features: ['Multi-node', 'Local Testing', 'Custom Config'], + color: 'from-indigo-500 to-indigo-600' + } +]; + +const subdomains = [ + { name: 'Explorer', href: '/explorer', icon: , external: false }, + { name: 'Docs', href: '/docs', icon: , external: false }, + { name: 'Wallet', href: '/wallet', icon: , external: false }, + { name: 'API', href: '/api', icon: , external: false }, + { name: 'Faucet', href: '/faucet', icon: , external: false }, + { name: 'Developers', href: '/developers', icon: , external: false }, + { name: 'Grants', href: '/grants', icon: , external: false }, + { name: 'Wiki', href: '/wiki', icon: , external: false }, + { name: 'Forum', href: '/forum', icon: , external: false }, + { name: 'Telemetry', href: '/telemetry', icon: , external: false }, +] + +const ChainSpecs: React.FC = () => { + const { t } = useTranslation(); + const [copiedId, setCopiedId] = useState(null); + const [selectedSpec] = useState(chainSpecs[0]); + const navigate = useNavigate(); + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + + return ( +
+
+
+

+ {t('chainSpecs.title')} +

+

+ {t('chainSpecs.subtitle')} +

+
+ +
+ {chainSpecs.map((spec) => ( +
navigate(`/${spec.id}`)} + className={`cursor-pointer p-4 rounded-xl border transition-all ${ + selectedSpec.id === spec.id + ? 'bg-gray-900 border-purple-500' + : 'bg-gray-950/50 border-gray-800 hover:border-gray-700' + }`} + > +
+
+ {spec.icon} +
+ + {spec.type} + +
+ +

{spec.name}

+ +
+ + {spec.validators} validators +
+
+ ))} + + {/* Subdomains Box */} +
navigate('/subdomains')} + className="md:col-span-2 lg:col-span-1 cursor-pointer p-4 rounded-xl border transition-all bg-gray-950/50 border-gray-800 hover:border-gray-700" + > +
+
+ +
+ + {t('chainSpecs.services')} + +
+

{t('chainSpecs.subdomainsTitle')}

+
+ + {t('chainSpecs.availableServices', { count: subdomains.length })} +
+
+
+ + {/* Selected Chain Details */} +
+
+
+

+
+ {selectedSpec.icon} +
+ {selectedSpec.name} +

+ +
+
+ +
+ + {selectedSpec.endpoint} + + +
+
+ +
+ +
+ + {selectedSpec.chainId} + + +
+
+ +
+ +
+
+
+ +
+

{t('chainSpecs.availableSubdomains')}

+
+ {subdomains.map(subdomain => ( +
navigate(subdomain.href)} className="flex items-center p-3 bg-gray-900 rounded-lg cursor-pointer hover:bg-gray-800 transition-colors"> +
{subdomain.icon}
+ {subdomain.name} +
+ ))} +
+
+
+
+
+
+ ); +}; + +export default ChainSpecs; \ No newline at end of file diff --git a/packages/apps/src/components/ErrorBoundary.tsx b/packages/apps/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..a787365 --- /dev/null +++ b/packages/apps/src/components/ErrorBoundary.tsx @@ -0,0 +1,243 @@ +// ======================================== +// Error Boundary Component +// ======================================== +// Catches React errors and displays fallback UI + +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle, RefreshCw, Home } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * Global Error Boundary + * Catches unhandled errors in React component tree + * + * @example + * + * + * + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // Update state so next render shows fallback UI + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Log error to console + if (process.env.NODE_ENV !== 'production') console.error('ErrorBoundary caught an error:', error, errorInfo); + + // Update state with error details + this.setState({ + error, + errorInfo, + }); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // In production, you might want to log to an error reporting service + // Example: Sentry.captureException(error); + } + + handleReset = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + handleReload = (): void => { + window.location.reload(); + }; + + handleGoHome = (): void => { + window.location.href = '/'; + }; + + render(): ReactNode { + if (this.state.hasError) { + // Use custom fallback if provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI + return ( +
+ + + + + +

Something Went Wrong

+

+ An unexpected error occurred. We apologize for the inconvenience. +

+ {this.state.error && ( +
+ + Error Details (for developers) + +
+
+ Error: +
+                            {this.state.error.toString()}
+                          
+
+ {this.state.errorInfo && ( +
+ Component Stack: +
+                              {this.state.errorInfo.componentStack}
+                            
+
+ )} +
+
+ )} +
+
+ +
+ + + +
+ +

+ If this problem persists, please contact support at{' '} + + info@pezkuwichain.io + +

+
+
+
+ ); + } + + // No error, render children normally + return this.props.children; + } +} + +// ======================================== +// ROUTE-LEVEL ERROR BOUNDARY +// ======================================== + +/** + * Smaller error boundary for individual routes + * Less intrusive, doesn't take over the whole screen + */ +export const RouteErrorBoundary: React.FC<{ + children: ReactNode; + routeName?: string; +}> = ({ children, routeName = 'this page' }) => { + const [hasError, setHasError] = React.useState(false); + + const handleReset = () => { + setHasError(false); + }; + + if (hasError) { + return ( +
+ + + + Error loading {routeName} + An error occurred while rendering this component. +
+ +
+
+
+
+ ); + } + + return ( + }> + {children} + + ); +}; + +const RouteErrorFallback: React.FC<{ routeName: string; onReset: () => void }> = ({ + routeName, + onReset, +}) => { + return ( +
+ + + + Error loading {routeName} + An unexpected error occurred. +
+ +
+
+
+
+ ); +}; diff --git a/packages/apps/src/components/GovernanceInterface.tsx b/packages/apps/src/components/GovernanceInterface.tsx new file mode 100644 index 0000000..09207a5 --- /dev/null +++ b/packages/apps/src/components/GovernanceInterface.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { TrendingUp, FileText, Users, Shield, Vote, History } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; +import GovernanceOverview from './governance/GovernanceOverview'; +import ProposalsList from './governance/ProposalsList'; +import ElectionsInterface from './governance/ElectionsInterface'; +import DelegationManager from './delegation/DelegationManager'; +import MyVotes from './governance/MyVotes'; +import GovernanceHistory from './governance/GovernanceHistory'; + +const GovernanceInterface: React.FC = () => { + const [activeTab, setActiveTab] = useState('overview'); + + return ( +
+
+
+

+ + On-Chain Governance + +

+

+ Participate in PezkuwiChain's decentralized governance. Vote on proposals, elect representatives, and shape the future of the network. +

+
+ + + + + + Overview + + + + Proposals + + + + Elections + + + + Delegation + + + + My Votes + + + + History + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default GovernanceInterface; diff --git a/packages/apps/src/components/HeroSection.tsx b/packages/apps/src/components/HeroSection.tsx new file mode 100644 index 0000000..afe4d5b --- /dev/null +++ b/packages/apps/src/components/HeroSection.tsx @@ -0,0 +1,165 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight, Shield } from 'lucide-react'; +import { usePezkuwi } from '../contexts/PezkuwiContext'; +import { useWallet } from '../contexts/WalletContext'; // Import useWallet +import { formatBalance } from '@pezkuwi/lib/wallet'; + +const HeroSection: React.FC = () => { + const { t } = useTranslation(); + const { api, isApiReady } = usePezkuwi(); + const { selectedAccount } = useWallet(); // Use selectedAccount from WalletContext + const [stats, setStats] = useState({ + activeProposals: 0, + totalVoters: 0, + tokensStaked: '0', + trustScore: 0 + }); + + useEffect(() => { + const fetchStats = async () => { + if (!api || !isApiReady) return; + + let currentTrustScore = 0; // Default if not fetched or no account + if (selectedAccount?.address) { + try { + // Assuming pallet-staking-score has a storage item for trust scores + // The exact query might need adjustment based on chain metadata + const rawTrustScore = await api.query.stakingScore.trustScore(selectedAccount.address); + // Assuming trustScore is a simple number or a wrapper around it + currentTrustScore = rawTrustScore.isSome ? rawTrustScore.unwrap().toNumber() : 0; + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch trust score:', err); + currentTrustScore = 0; + } + } + + try { + // Fetch active referenda + let activeProposals = 0; + try { + const referendaCount = await api.query.referenda.referendumCount(); + activeProposals = referendaCount.toNumber(); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch referenda:', err); + } + + // Fetch total staked tokens + let tokensStaked = '0'; + try { + const currentEra = await api.query.staking.currentEra(); + if (currentEra.isSome) { + const eraIndex = currentEra.unwrap().toNumber(); + const totalStake = await api.query.staking.erasTotalStake(eraIndex); + const formatted = formatBalance(totalStake.toString()); + tokensStaked = `${formatted} HEZ`; + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch total stake:', err); + } + + // Count total voters from conviction voting + let totalVoters = 0; + try { + // Get all voting keys and count unique voters + const votingKeys = await api.query.convictionVoting.votingFor.keys(); + // Each key represents a unique (account, track) pair + // Count unique accounts + const uniqueAccounts = new Set(votingKeys.map(key => key.args[0].toString())); + totalVoters = uniqueAccounts.size; + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch voters:', err); + } + + // Update stats + setStats({ + activeProposals, + totalVoters, + tokensStaked, + trustScore: currentTrustScore + }); + + if (process.env.NODE_ENV !== 'production') console.log('✅ Hero stats updated:', { + activeProposals, + totalVoters, + tokensStaked, + trustScore: currentTrustScore + }); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch hero stats:', error); + } + }; + + fetchStats(); + }, [api, isApiReady, selectedAccount]); // Add selectedAccount to dependencies + + return ( +
+ {/* Background Image */} +
+ DKstate Background +
+
+ + {/* Content */} +
+
+ + Digital Kurdistan State v1.0 +
+ +

+ PezkuwiChain +

+ +

+ {t('hero.title', 'Blockchain Governance Platform')} +

+

+ {t('hero.subtitle', 'Democratic and transparent governance with blockchain technology')} +

+ +
+
+
{stats.activeProposals}
+
{t('hero.stats.activeProposals', 'Active Proposals')}
+
+
+
{stats.totalVoters.toLocaleString()}
+
{t('hero.stats.totalVoters', 'Total Voters')}
+
+
+
{stats.tokensStaked}
+
{t('hero.stats.tokensStaked', 'Tokens Staked')}
+
+
+
{stats.trustScore}%
+
{t('hero.stats.trustScore', 'Trust Score')}
+
+
+ +
+ + +
+
+
+ ); +}; + +export default HeroSection; diff --git a/packages/apps/src/components/KurdistanSun.tsx b/packages/apps/src/components/KurdistanSun.tsx new file mode 100644 index 0000000..4fca9e3 --- /dev/null +++ b/packages/apps/src/components/KurdistanSun.tsx @@ -0,0 +1,188 @@ +import React from 'react'; + +interface KurdistanSunProps { + size?: number; + className?: string; +} + +export const KurdistanSun: React.FC = ({ size = 200, className = '' }) => { + return ( +
+ {/* Rotating colored halos */} +
+ {/* Green halo (outermost) */} +
+ {/* Red halo (middle) */} +
+ {/* Yellow halo (inner) */} +
+
+ + {/* Kurdistan Sun with 21 rays */} + + {/* Sun rays (21 rays for Kurdistan flag) */} + + {Array.from({ length: 21 }).map((_, i) => { + const angle = (i * 360) / 21; + return ( + + ); + })} + + + {/* Central white circle */} + + + {/* Inner glow */} + + + + + + + + + + + +
+ ); +}; diff --git a/packages/apps/src/components/LanguageSwitcher.tsx b/packages/apps/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..7c4d226 --- /dev/null +++ b/packages/apps/src/components/LanguageSwitcher.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Globe } from 'lucide-react'; +import { languages } from '@/i18n/config'; +import { useEffect } from 'react'; + +export function LanguageSwitcher() { + const { i18n } = useTranslation(); + + useEffect(() => { + // Update document direction based on language + const currentLang = languages[i18n.language as keyof typeof languages]; + if (currentLang) { + document.documentElement.dir = currentLang.dir; + document.documentElement.lang = i18n.language; + } + }, [i18n.language]); + + const changeLanguage = (lng: string) => { + i18n.changeLanguage(lng); + const lang = languages[lng as keyof typeof languages]; + if (lang) { + document.documentElement.dir = lang.dir; + document.documentElement.lang = lng; + } + }; + + const currentLanguage = languages[i18n.language as keyof typeof languages] || languages.en; + + return ( + + + + + + {Object.entries(languages).map(([code, lang]) => ( + changeLanguage(code)} + className={`cursor-pointer ${i18n.language === code ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`} + > + {lang.flag} + {lang.name} + + ))} + + + ); +} \ No newline at end of file diff --git a/packages/apps/src/components/Layout.tsx b/packages/apps/src/components/Layout.tsx new file mode 100644 index 0000000..e4745b3 --- /dev/null +++ b/packages/apps/src/components/Layout.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Link, NavLink } from 'react-router-dom'; + +const PezkuwiChainLogo: React.FC = () => { + return ( + PezkuwiChain Logo + ); +}; + +const Header: React.FC = () => { + const linkStyle = "text-white hover:text-green-400 transition-colors"; + const activeLinkStyle = { color: '#34D399' }; // green-400 + + return ( +
+
+ + + + +
+
+ ); +}; + +const Footer: React.FC = () => { + return ( +
+
+

© {new Date().getFullYear()} PezkuwiChain. All rights reserved.

+
+
+ ); +}; + +interface LayoutProps { + children: React.ReactNode; +} + +const Layout: React.FC = ({ children }) => { + return ( +
+
+
{/* Add padding-top equal to header height */} +
+ {children} +
+
+
+
+ ); +}; + +export default Layout; diff --git a/packages/apps/src/components/MultisigMembers.tsx b/packages/apps/src/components/MultisigMembers.tsx new file mode 100644 index 0000000..ef9de6c --- /dev/null +++ b/packages/apps/src/components/MultisigMembers.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect } from 'react'; +import { Shield, Users, CheckCircle, XCircle, ExternalLink } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { + getMultisigMemberInfo, + calculateMultisigAddress, + USDT_MULTISIG_CONFIG, + formatMultisigAddress, +} from '@pezkuwi/lib/multisig'; +import { getTikiDisplayName, getTikiEmoji } from '@pezkuwi/lib/tiki'; + +interface MultisigMembersProps { + specificAddresses?: Record; + showMultisigAddress?: boolean; +} + +interface MultisigMember { + address: string; + displayName: string; + emoji: string; + role: string; + isTiki: boolean; + trustScore?: number; + balance?: string; +} + +export const MultisigMembers: React.FC = ({ + specificAddresses = {}, + showMultisigAddress = true, +}) => { + const { api, isApiReady } = usePezkuwi(); + const [members, setMembers] = useState([]); + const [multisigAddress, setMultisigAddress] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!api || !isApiReady) return; + + const fetchMembers = async () => { + setLoading(true); + try { + const memberInfo = await getMultisigMemberInfo(api, specificAddresses); + setMembers(memberInfo); + + // Calculate multisig address + const addresses = memberInfo.map((m) => m.address); + if (addresses.length > 0) { + const multisig = calculateMultisigAddress(addresses); + setMultisigAddress(multisig); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching multisig members:', error); + } finally { + setLoading(false); + } + }; + + fetchMembers(); + }, [api, isApiReady, specificAddresses]); + + if (loading) { + return ( + +
+
+
+
+ ); + } + + return ( + + {/* Header */} +
+
+ +
+

USDT Treasury Multisig

+

+ {USDT_MULTISIG_CONFIG.threshold}/{members.length} Signatures Required +

+
+
+ + + {members.length} Members + +
+ + {/* Multisig Address */} + {showMultisigAddress && multisigAddress && ( +
+

Multisig Account

+
+ {formatMultisigAddress(multisigAddress)} + +
+
+ )} + + {/* Members List */} +
+ {members.map((member, index) => ( +
+
+
+ {getTikiEmoji(member.tiki)} +
+
+

{member.role}

+
+ + {getTikiDisplayName(member.tiki)} + + {member.isUnique && ( + + + On-Chain + + )} +
+
+
+ +
+ + {member.address.slice(0, 6)}...{member.address.slice(-4)} + +
+ {member.isUnique ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ + {/* Info Alert */} + + + +

Security Features

+
    +
  • • {USDT_MULTISIG_CONFIG.threshold} out of {members.length} signatures required
  • +
  • • {members.filter(m => m.isUnique).length} members verified on-chain via Tiki
  • +
  • • No single person can control funds
  • +
  • • All transactions visible on blockchain
  • +
+
+
+ + {/* Explorer Link */} + {multisigAddress && ( + + )} +
+ ); +}; diff --git a/packages/apps/src/components/NetworkStats.tsx b/packages/apps/src/components/NetworkStats.tsx new file mode 100644 index 0000000..125a0fb --- /dev/null +++ b/packages/apps/src/components/NetworkStats.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Activity, Wifi, WifiOff, Users, Box, TrendingUp } from 'lucide-react'; + +export const NetworkStats: React.FC = () => { + const { api, isApiReady, error } = usePezkuwi(); + const [blockNumber, setBlockNumber] = useState(0); + const [blockHash, setBlockHash] = useState(''); + const [finalizedBlock, setFinalizedBlock] = useState(0); + const [validatorCount, setValidatorCount] = useState(0); + const [collatorCount, setCollatorCount] = useState(0); + const [nominatorCount, setNominatorCount] = useState(0); + const [peers, setPeers] = useState(0); + + useEffect(() => { + if (!api || !isApiReady) return; + + let unsubscribeNewHeads: () => void; + let unsubscribeFinalizedHeads: () => void; + let intervalId: NodeJS.Timeout; + + const subscribeToBlocks = async () => { + try { + // Subscribe to new blocks + unsubscribeNewHeads = await api.rpc.chain.subscribeNewHeads((header) => { + setBlockNumber(header.number.toNumber()); + setBlockHash(header.hash.toHex()); + }); + + // Subscribe to finalized blocks + unsubscribeFinalizedHeads = await api.rpc.chain.subscribeFinalizedHeads((header) => { + setFinalizedBlock(header.number.toNumber()); + }); + + // Update validator count, collator count, nominator count, and peer count every 3 seconds + const updateNetworkStats = async () => { + try { + const health = await api.rpc.system.health(); + + // 1. Fetch Validators + let vCount = 0; + try { + if (api.query.session?.validators) { + const validators = await api.query.session.validators(); + if (validators) { + vCount = validators.length; + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch validators', err); + } + + // 2. Fetch Collators (Invulnerables) + let cCount = 0; + try { + if (api.query.collatorSelection?.invulnerables) { + const invulnerables = await api.query.collatorSelection.invulnerables(); + if (invulnerables) { + cCount = invulnerables.length; + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to fetch collators', err); + } + + // 3. Count Nominators + let nCount = 0; + try { + const nominators = await api.query.staking?.nominators.entries(); + if (nominators) { + nCount = nominators.length; + } + } catch { + if (process.env.NODE_ENV !== 'production') console.warn('Staking pallet not available, nominators = 0'); + } + + setValidatorCount(vCount); + setCollatorCount(cCount); + setNominatorCount(nCount); + setPeers(health.peers.toNumber()); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to update network stats:', err); + } + }; + + // Initial update + await updateNetworkStats(); + + // Update every 3 seconds + intervalId = setInterval(updateNetworkStats, 3000); + + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to subscribe to blocks:', err); + } + }; + + subscribeToBlocks(); + + return () => { + if (unsubscribeNewHeads) unsubscribeNewHeads(); + if (unsubscribeFinalizedHeads) unsubscribeFinalizedHeads(); + if (intervalId) clearInterval(intervalId); + }; + }, [api, isApiReady]); + + if (error) { + return ( + + + + + Network Disconnected + + + +

{error}

+

+ Make sure your validator node is running at ws://127.0.0.1:9944 +

+
+
+ ); + } + + if (!isApiReady) { + return ( + + + + + Connecting to Network... + + + + ); + } + + return ( +
+ {/* Connection Status */} + + + + + Network Status + + + +
+ + Connected + + {peers} peers +
+
+
+ + {/* Latest Block */} + + + + + Latest Block + + + +
+
+ #{blockNumber.toLocaleString()} +
+
+ {blockHash.slice(0, 10)}...{blockHash.slice(-8)} +
+
+
+
+ + {/* Finalized Block */} + + + + + Finalized Block + + + +
+ #{finalizedBlock.toLocaleString()} +
+
+ {blockNumber - finalizedBlock} blocks behind +
+
+
+ + {/* Validators */} + + + + + Active Validators + + + +
+ {validatorCount} +
+
+ Validating blocks +
+
+
+ + {/* Collators */} + + + + + Active Collators + + + +
+ {collatorCount} +
+
+ Producing blocks +
+
+
+ + {/* Nominators */} + + + + + Active Nominators + + + +
+ {nominatorCount} +
+
+ Staking to validators +
+
+
+
+ ); +}; diff --git a/packages/apps/src/components/NftList.tsx b/packages/apps/src/components/NftList.tsx new file mode 100644 index 0000000..9db9a95 --- /dev/null +++ b/packages/apps/src/components/NftList.tsx @@ -0,0 +1,175 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Award, Crown, Shield, Users } from 'lucide-react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { getUserTikis } from '@pezkuwi/lib/citizenship-workflow'; +import type { TikiInfo } from '@pezkuwi/lib/citizenship-workflow'; + +// Icon map for different Tiki roles +const getTikiIcon = (role: string) => { + const roleLower = role.toLowerCase(); + + if (roleLower.includes('welati') || roleLower.includes('citizen')) { + return ; + } + if (roleLower.includes('serok') || roleLower.includes('leader') || roleLower.includes('chief')) { + return ; + } + if (roleLower.includes('axa') || roleLower.includes('hekem') || roleLower.includes('elder') || roleLower.includes('wise')) { + return ; + } + return ; +}; + +// Color scheme for different roles +const getRoleBadgeColor = (role: string) => { + const roleLower = role.toLowerCase(); + + if (roleLower.includes('welati') || roleLower.includes('citizen')) { + return 'bg-cyan-500/10 text-cyan-500 border-cyan-500/30'; + } + if (roleLower.includes('serok') || roleLower.includes('leader') || roleLower.includes('chief')) { + return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30'; + } + if (roleLower.includes('axa') || roleLower.includes('hekem') || roleLower.includes('elder') || roleLower.includes('wise')) { + return 'bg-purple-500/10 text-purple-500 border-purple-500/30'; + } + return 'bg-green-500/10 text-green-500 border-green-500/30'; +}; + +export const NftList: React.FC = () => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const [tikis, setTikis] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchTikis = async () => { + if (!api || !isApiReady || !selectedAccount) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const userTikis = await getUserTikis(api, selectedAccount.address); + setTikis(userTikis); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching Tikis:', err); + setError('Failed to load NFTs'); + } finally { + setLoading(false); + } + }; + + fetchTikis(); + }, [api, isApiReady, selectedAccount]); + + if (loading) { + return ( + + + Your NFTs (Tikis) + Your Tiki collection + + +
+ +
+
+
+ ); + } + + if (error) { + return ( + + + Your NFTs (Tikis) + Your Tiki collection + + +
+

{error}

+
+
+
+ ); + } + + if (tikis.length === 0) { + return ( + + + Your NFTs (Tikis) + Your Tiki collection + + +
+ +

No NFTs yet

+

+ Complete your citizenship application to receive your Welati Tiki NFT +

+
+
+
+ ); + } + + return ( + + + + + Your NFTs (Tikiler) + + Your Tiki collection ({tikis.length} total) + + +
+ {tikis.map((tiki, index) => ( +
+
+ {/* Icon */} +
+ {getTikiIcon(tiki.role)} +
+ + {/* Info */} +
+
+

+ Tiki #{tiki.id} +

+ + {tiki.role} + +
+ + {/* Metadata if available */} + {tiki.metadata && typeof tiki.metadata === 'object' && ( +
+ {Object.entries(tiki.metadata).map(([key, value]) => ( +
+ {key}:{' '} + {String(value)} +
+ ))} +
+ )} +
+
+
+ ))} +
+
+
+ ); +}; diff --git a/packages/apps/src/components/PalletsGrid.tsx b/packages/apps/src/components/PalletsGrid.tsx new file mode 100644 index 0000000..2970d73 --- /dev/null +++ b/packages/apps/src/components/PalletsGrid.tsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import { Code, Database, TrendingUp, Gift, Award } from 'lucide-react'; + +interface Pallet { + id: string; + name: string; + icon: React.ReactNode; + description: string; + image: string; + extrinsics: string[]; + storage: string[]; +} + +const pallets: Pallet[] = [ + { + id: 'pez-treasury', + name: 'PEZ Treasury', + icon: , + description: 'Manages token distribution with 48-month synthetic halving mechanism', + image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315321470_3d093f4f.webp', + extrinsics: ['initialize_treasury', 'release_monthly_funds', 'force_genesis_distribution'], + storage: ['HalvingInfo', 'MonthlyReleases', 'TreasuryStartBlock'] + }, + { + id: 'trust', + name: 'Trust Score', + icon: , + description: 'Calculates weighted trust scores from multiple components', + image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315323202_06631fb8.webp', + extrinsics: ['force_recalculate_trust_score', 'update_all_trust_scores', 'periodic_trust_score_update'], + storage: ['TrustScores', 'TotalActiveTrustScore', 'BatchUpdateInProgress'] + }, + { + id: 'staking-score', + name: 'Staking Score', + icon: , + description: 'Time-based staking multipliers from 1.0x to 2.0x over 12 months', + image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315324943_84216eda.webp', + extrinsics: ['start_score_tracking'], + storage: ['StakingStartBlock'] + }, + { + id: 'pez-rewards', + name: 'PEZ Rewards', + icon: , + description: 'Monthly epoch-based reward distribution system', + image: 'https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760315326731_ca5f9a92.webp', + extrinsics: ['initialize_rewards_system', 'record_trust_score', 'finalize_epoch', 'claim_reward'], + storage: ['EpochInfo', 'EpochRewardPools', 'UserEpochScores', 'ClaimedRewards'] + } +]; + +const PalletsGrid: React.FC = () => { + const [selectedPallet, setSelectedPallet] = useState(null); + + return ( +
+
+
+

+ Core Runtime Pallets +

+

+ Modular blockchain components powering PezkuwiChain's advanced features +

+
+ +
+ {pallets.map((pallet) => ( +
setSelectedPallet(pallet)} + className="group relative bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-800 hover:border-purple-500/50 transition-all cursor-pointer overflow-hidden" + > + {/* Background Glow */} +
+ +
+
+ {pallet.name} +
+
+
+ {pallet.icon} +
+

{pallet.name}

+
+

{pallet.description}

+ +
+ + {pallet.extrinsics.length} Extrinsics + + + {pallet.storage.length} Storage Items + +
+
+
+
+
+ ))} +
+
+ + {/* Modal */} + {selectedPallet && ( +
setSelectedPallet(null)} + > +
e.stopPropagation()} + > +
+
+

{selectedPallet.name}

+ +
+ +
+
+

Extrinsics

+
+ {selectedPallet.extrinsics.map((ext) => ( +
+ + {ext}() +
+ ))} +
+
+ +
+

Storage Items

+
+ {selectedPallet.storage.map((item) => ( +
+ + {item} +
+ ))} +
+
+
+
+
+
+ )} +
+ ); +}; + +export default PalletsGrid; \ No newline at end of file diff --git a/packages/apps/src/components/PezkuwiWalletButton.tsx b/packages/apps/src/components/PezkuwiWalletButton.tsx new file mode 100644 index 0000000..472aa61 --- /dev/null +++ b/packages/apps/src/components/PezkuwiWalletButton.tsx @@ -0,0 +1,243 @@ +import React, { useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Wallet, Check, ExternalLink, Copy, LogOut } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +export const PezkuwiWalletButton: React.FC = () => { + const { + accounts, + selectedAccount, + setSelectedAccount, + connectWallet, + disconnectWallet, + error + } = usePezkuwi(); + + const [isOpen, setIsOpen] = useState(false); + const { toast } = useToast(); + + const handleConnect = async () => { + await connectWallet(); + if (accounts.length > 0) { + setIsOpen(true); + } + }; + + const handleSelectAccount = (account: typeof accounts[0]) => { + setSelectedAccount(account); + setIsOpen(false); + toast({ + title: "Account Connected", + description: `${account.meta.name} - ${formatAddress(account.address)}`, + }); + }; + + const handleDisconnect = () => { + disconnectWallet(); + toast({ + title: "Wallet Disconnected", + description: "Your wallet has been disconnected", + }); + }; + + const formatAddress = (address: string) => { + return `${address.slice(0, 6)}...${address.slice(-4)}`; + }; + + const copyAddress = () => { + if (selectedAccount) { + navigator.clipboard.writeText(selectedAccount.address); + toast({ + title: "Address Copied", + description: "Address copied to clipboard", + }); + } + }; + + if (selectedAccount) { + return ( +
+ + + + + + + Account Details + + Your connected Pezkuwi account + + + +
+
+
Account Name
+
+ {selectedAccount.meta.name || 'Unnamed Account'} +
+
+ +
+
Address
+
+ + {selectedAccount.address} + + +
+
+ +
+
Source
+
+ {selectedAccount.meta.source || 'pezkuwi'} +
+
+ + {accounts.length > 1 && ( +
+
Switch Account
+
+ {accounts.map((account) => ( + + ))} +
+
+ )} +
+
+
+
+ ); + } + + return ( + <> + + + {error && error.includes('extension') && ( + {}}> + + + Install Pezkuwi.js Extension + + You need the Pezkuwi.js browser extension to connect your wallet + + + +
+

+ The Pezkuwi.js extension allows you to manage your accounts and sign transactions securely. +

+ + + +

+ After installing, refresh this page and click "Connect Wallet" again. +

+
+
+
+ )} + + 0} onOpenChange={setIsOpen}> + + + Select Account + + Choose an account to connect + + + +
+ {accounts.map((account) => ( + + ))} +
+
+
+ + ); +}; \ No newline at end of file diff --git a/packages/apps/src/components/PoolDashboard.tsx b/packages/apps/src/components/PoolDashboard.tsx new file mode 100644 index 0000000..5309527 --- /dev/null +++ b/packages/apps/src/components/PoolDashboard.tsx @@ -0,0 +1,619 @@ +import React, { useState, useEffect } from 'react'; +import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; +import { AddLiquidityModal } from '@/components/AddLiquidityModal'; +import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal'; + +// Helper function to convert asset IDs to user-friendly display names +// Users should only see HEZ, PEZ, USDT - wrapped tokens are backend details +const getDisplayTokenName = (assetId: number): string => { + if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ'; + if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; + if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; + return getAssetSymbol(assetId); // Fallback for other assets +}; + +// Helper function to get decimals for each asset +const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ have 12 decimals +}; + +interface PoolData { + asset0: number; + asset1: number; + reserve0: number; + reserve1: number; + lpTokenId: number; + poolAccount: string; +} + +interface LPPosition { + lpTokenBalance: number; + share: number; // Percentage of pool + asset0Amount: number; + asset1Amount: number; +} + +const PoolDashboard = () => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + + const [poolData, setPoolData] = useState(null); + const [lpPosition, setLPPosition] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false); + const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false); + + // Pool selection state + const [availablePools, setAvailablePools] = useState>([]); + const [selectedPool, setSelectedPool] = useState('0-1'); // Default: wHEZ/PEZ + + // Discover available pools + useEffect(() => { + if (!api || !isApiReady) return; + + const discoverPools = async () => { + try { + // Check all possible pool combinations in both directions + // Pools can be stored as [A,B] or [B,A] depending on creation order + // Note: .env sets WUSDT to Asset ID based on VITE_ASSET_WUSDT + const possiblePools: Array<[number, number]> = [ + [ASSET_IDS.WHEZ, ASSET_IDS.PEZ], // wHEZ(0) / PEZ(1) -> Shows as HEZ-PEZ + [ASSET_IDS.PEZ, ASSET_IDS.WHEZ], // PEZ(1) / wHEZ(0) -> Shows as HEZ-PEZ (reverse) + [ASSET_IDS.WHEZ, ASSET_IDS.WUSDT], // wHEZ(0) / wUSDT -> Shows as HEZ-USDT + [ASSET_IDS.WUSDT, ASSET_IDS.WHEZ], // wUSDT / wHEZ(0) -> Shows as HEZ-USDT (reverse) + [ASSET_IDS.PEZ, ASSET_IDS.WUSDT], // PEZ(1) / wUSDT -> Shows as PEZ-USDT + [ASSET_IDS.WUSDT, ASSET_IDS.PEZ], // wUSDT / PEZ(1) -> Shows as PEZ-USDT (reverse) + ]; + + const existingPools: Array<[number, number]> = []; + + for (const [asset0, asset1] of possiblePools) { + try { + const poolInfo = await api.query.assetConversion.pools([asset0, asset1]); + if (poolInfo.isSome) { + existingPools.push([asset0, asset1]); + if (process.env.NODE_ENV !== 'production') { + console.log(`✅ Found pool: ${asset0}-${asset1}`); + } + } + } catch (err) { + // Skip pools that error out (likely don't exist) + if (process.env.NODE_ENV !== 'production') { + console.log(`❌ Pool ${asset0}-${asset1} not found or error:`, err); + } + } + } + + if (process.env.NODE_ENV !== 'production') { + console.log('📊 Total pools found:', existingPools.length, existingPools); + } + + setAvailablePools(existingPools); + + // Set default pool to first available if current selection doesn't exist + if (existingPools.length > 0) { + const currentPoolKey = selectedPool; + const poolExists = existingPools.some( + ([a0, a1]) => `${a0}-${a1}` === currentPoolKey + ); + if (!poolExists) { + const [firstAsset0, firstAsset1] = existingPools[0]; + setSelectedPool(`${firstAsset0}-${firstAsset1}`); + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error discovering pools:', err); + } + }; + + discoverPools(); + }, [api, isApiReady, selectedPool]); + + + // Fetch pool data + useEffect(() => { + if (!api || !isApiReady || !selectedPool) return; + + const fetchPoolData = async () => { + setIsLoading(true); + setError(null); + + try { + // Parse selected pool (e.g., "1-2" -> [1, 2]) + const [asset1Str, asset2Str] = selectedPool.split('-'); + const asset1 = parseInt(asset1Str); + const asset2 = parseInt(asset2Str); + const poolId = [asset1, asset2]; + + const poolInfo = await api.query.assetConversion.pools(poolId); + + if (poolInfo.isSome) { + const lpTokenData = poolInfo.unwrap().toJSON() as Record; + const lpTokenId = lpTokenData.lpToken; + + // Derive pool account using AccountIdConverter + const { stringToU8a } = await import('@pezkuwi/util'); + const { blake2AsU8a } = await import('@pezkuwi/util-crypto'); + + // PalletId for AssetConversion: "py/ascon" (8 bytes) + const PALLET_ID = stringToU8a('py/ascon'); + + // Create PoolId tuple (u32, u32) + const poolIdType = api.createType('(u32, u32)', [asset1, asset2]); + + // Create (PalletId, PoolId) tuple: ([u8; 8], (u32, u32)) + const palletIdType = api.createType('[u8; 8]', PALLET_ID); + const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolIdType]); + + // Hash the SCALE-encoded tuple + const accountHash = blake2AsU8a(fullTuple.toU8a(), 256); + const poolAccountId = api.createType('AccountId32', accountHash); + const poolAccount = poolAccountId.toString(); + + // Get reserves + const asset0BalanceData = await api.query.assets.account(asset1, poolAccountId); + const asset1BalanceData = await api.query.assets.account(asset2, poolAccountId); + + let reserve0 = 0; + let reserve1 = 0; + + // Use dynamic decimals for each asset + const asset1Decimals = getAssetDecimals(asset1); + const asset2Decimals = getAssetDecimals(asset2); + + if (asset0BalanceData.isSome) { + const asset0Data = asset0BalanceData.unwrap().toJSON() as Record; + reserve0 = Number(asset0Data.balance) / Math.pow(10, asset1Decimals); + } + + if (asset1BalanceData.isSome) { + const asset1Data = asset1BalanceData.unwrap().toJSON() as Record; + reserve1 = Number(asset1Data.balance) / Math.pow(10, asset2Decimals); + } + + setPoolData({ + asset0: asset1, + asset1: asset2, + reserve0, + reserve1, + lpTokenId, + poolAccount, + }); + + // Get user's LP position if account connected + if (selectedAccount) { + await fetchLPPosition(lpTokenId, reserve0, reserve1); + } + } else { + setError('Pool not found'); + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching pool data:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch pool data'); + } finally { + setIsLoading(false); + } + }; + + const fetchLPPosition = async (lpTokenId: number, reserve0: number, reserve1: number) => { + if (!api || !selectedAccount) return; + + try { + // Query user's LP token balance + const lpBalance = await api.query.poolAssets.account(lpTokenId, selectedAccount.address); + + if (lpBalance.isSome) { + const lpData = lpBalance.unwrap().toJSON() as Record; + const userLpBalance = Number(lpData.balance) / 1e12; + + // Query total LP supply + const lpAssetData = await api.query.poolAssets.asset(lpTokenId); + + if (lpAssetData.isSome) { + const assetInfo = lpAssetData.unwrap().toJSON() as Record; + const totalSupply = Number(assetInfo.supply) / 1e12; + + // Calculate user's share + const sharePercentage = (userLpBalance / totalSupply) * 100; + + // Calculate user's actual token amounts + const asset0Amount = (sharePercentage / 100) * reserve0; + const asset1Amount = (sharePercentage / 100) * reserve1; + + setLPPosition({ + lpTokenBalance: userLpBalance, + share: sharePercentage, + asset0Amount, + asset1Amount, + }); + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching LP position:', err); + } + }; + + fetchPoolData(); + + // Refresh every 30 seconds + const interval = setInterval(fetchPoolData, 30000); + + return () => clearInterval(interval); + }, [api, isApiReady, selectedAccount, selectedPool]); + + // Calculate metrics + const constantProduct = poolData ? poolData.reserve0 * poolData.reserve1 : 0; + const currentPrice = poolData ? poolData.reserve1 / poolData.reserve0 : 0; + const totalLiquidityUSD = poolData ? poolData.reserve0 * 2 : 0; // Simplified: assumes 1:1 USD peg + + // APR calculation (simplified - would need 24h volume data) + const estimateAPR = () => { + if (!poolData) return 0; + + // Estimate based on pool size and typical volume + // This is a simplified calculation + // Real APR = (24h fees × 365) / TVL + const dailyVolumeEstimate = totalLiquidityUSD * 0.1; // Assume 10% daily turnover + const dailyFees = dailyVolumeEstimate * 0.03; // 3% fee + const annualFees = dailyFees * 365; + const apr = (annualFees / totalLiquidityUSD) * 100; + + return apr; + }; + + // Impermanent loss calculator + const calculateImpermanentLoss = (priceChange: number) => { + // IL formula: 2 * sqrt(price_ratio) / (1 + price_ratio) - 1 + const priceRatio = 1 + priceChange / 100; + const il = ((2 * Math.sqrt(priceRatio)) / (1 + priceRatio) - 1) * 100; + return il; + }; + + if (isLoading && !poolData) { + return ( +
+
+
+

Loading pool data...

+
+
+ ); + } + + if (error) { + return ( + + + {error} + + ); + } + + if (!poolData) { + return ( + + + No pool data available + + ); + } + + // Get asset symbols for the selected pool (using display names) + const asset0Symbol = poolData ? getDisplayTokenName(poolData.asset0) : ''; + const asset1Symbol = poolData ? getDisplayTokenName(poolData.asset1) : ''; + + return ( +
+ {/* Pool Selector */} +
+
+
+

Pool Dashboards

+ +
+
+ + + Live + +
+ + {/* Pool Dashboard Title */} +
+
+

+ + {asset0Symbol}/{asset1Symbol} Pool Dashboard +

+

Monitor liquidity pool metrics and your position

+
+
+ + {/* Key Metrics Grid */} +
+ {/* Total Liquidity */} + +
+
+

Total Liquidity

+

+ ${totalLiquidityUSD.toLocaleString('en-US', { maximumFractionDigits: 0 })} +

+

+ {poolData.reserve0.toLocaleString()} {asset0Symbol} + {poolData.reserve1.toLocaleString()} {asset1Symbol} +

+
+ +
+
+ + {/* Current Price */} + +
+
+

{asset0Symbol} Price

+

+ ${currentPrice.toFixed(4)} +

+

+ 1 {asset1Symbol} = {(1 / currentPrice).toFixed(4)} {asset0Symbol} +

+
+ +
+
+ + {/* APR */} + +
+
+

Estimated APR

+

+ {estimateAPR().toFixed(2)}% +

+

+ From swap fees +

+
+ +
+
+ + {/* Constant Product */} + +
+
+

Constant (k)

+

+ {(constantProduct / 1e9).toFixed(1)}B +

+

+ x × y = k +

+
+ +
+
+
+ + + + Reserves + Your Position + IL Calculator + + + {/* Reserves Tab */} + + +

Pool Reserves

+ +
+
+
+

{asset0Symbol} Reserve

+

{poolData.reserve0.toLocaleString('en-US', { maximumFractionDigits: 2 })}

+
+ Asset 1 +
+ +
+
+

{asset1Symbol} Reserve

+

{poolData.reserve1.toLocaleString('en-US', { maximumFractionDigits: 2 })}

+
+ Asset 2 +
+
+ +
+
+ +
+

AMM Formula

+

Pool maintains constant product: x × y = k

+

+ {poolData.reserve0.toFixed(2)} × {poolData.reserve1.toFixed(2)} = {constantProduct.toLocaleString()} +

+
+
+
+
+
+ + {/* Your Position Tab */} + + +

Your Liquidity Position

+ + {!selectedAccount ? ( + + + Connect wallet to view your position + + ) : !lpPosition ? ( +
+ +

No liquidity position found

+ +
+ ) : ( +
+
+
+

LP Tokens

+

{lpPosition.lpTokenBalance.toFixed(4)}

+
+
+

Pool Share

+

{lpPosition.share.toFixed(4)}%

+
+
+ +
+

Your Position Value

+
+
+ {asset0Symbol}: + {lpPosition.asset0Amount.toFixed(4)} +
+
+ {asset1Symbol}: + {lpPosition.asset1Amount.toFixed(4)} +
+
+
+ +
+

Estimated Earnings (APR {estimateAPR().toFixed(2)}%)

+
+
+ Daily: + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 365 / 100).toFixed(4)} {asset0Symbol} +
+
+ Monthly: + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 12 / 100).toFixed(4)} {asset0Symbol} +
+
+ Yearly: + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 100).toFixed(4)} {asset0Symbol} +
+
+
+ +
+ + +
+
+ )} +
+
+ + {/* Impermanent Loss Calculator Tab */} + + +

Impermanent Loss Calculator

+ +
+
+

If {asset0Symbol} price changes by:

+ +
+ {[10, 25, 50, 100, 200].map((change) => { + const il = calculateImpermanentLoss(change); + return ( +
+ +{change}% + + {il.toFixed(2)}% Loss + +
+ ); + })} +
+
+ + + + +

What is Impermanent Loss?

+

+ Impermanent loss occurs when the price ratio of tokens in the pool changes. + The larger the price change, the greater the loss compared to simply holding the tokens. + Fees earned from swaps can offset this loss over time. +

+
+
+
+
+
+
+ + {/* Modals */} + setIsAddLiquidityModalOpen(false)} + asset0={poolData?.asset0} + asset1={poolData?.asset1} + /> + + {lpPosition && poolData && ( + setIsRemoveLiquidityModalOpen(false)} + lpPosition={lpPosition} + lpTokenId={poolData.lpTokenId} + asset0={poolData.asset0} + asset1={poolData.asset1} + /> + )} +
+ ); +}; + +export default PoolDashboard; diff --git a/packages/apps/src/components/ProtectedRoute.tsx b/packages/apps/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..f759f8f --- /dev/null +++ b/packages/apps/src/components/ProtectedRoute.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Loader2, Wallet } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requireAdmin?: boolean; +} + +export const ProtectedRoute: React.FC = ({ + children, + requireAdmin = false +}) => { + const { user, loading, isAdmin } = useAuth(); + const { selectedAccount, connectWallet } = usePezkuwi(); + const [walletRestoreChecked, setWalletRestoreChecked] = useState(false); + const [forceUpdate, setForceUpdate] = useState(0); + + // Listen for wallet changes + useEffect(() => { + const handleWalletChange = () => { + setForceUpdate(prev => prev + 1); + }; + + window.addEventListener('walletChanged', handleWalletChange); + return () => window.removeEventListener('walletChanged', handleWalletChange); + }, []); + + // Wait for wallet restoration (max 3 seconds) + useEffect(() => { + const timeout = setTimeout(() => { + setWalletRestoreChecked(true); + }, 3000); + + // If wallet restored earlier, clear timeout + if (selectedAccount) { + setWalletRestoreChecked(true); + clearTimeout(timeout); + } + + return () => clearTimeout(timeout); + }, [selectedAccount, forceUpdate]); + + // Show loading while: + // 1. Auth is loading, OR + // 2. Wallet restoration not checked yet + if (loading || !walletRestoreChecked) { + return ( +
+
+ +

+ {!walletRestoreChecked ? 'Restoring wallet connection...' : 'Loading...'} +

+
+
+ ); + } + + // For admin routes, require wallet connection + if (requireAdmin && !selectedAccount) { + const handleConnect = async () => { + await connectWallet(); + // Event is automatically dispatched by handleSetSelectedAccount wrapper + }; + + return ( +
+
+ +

Connect Your Wallet

+

+ Admin panel requires wallet authentication. Please connect your wallet to continue. +

+ +
+
+ ); + } + + if (!user) { + return ; + } + + if (requireAdmin && !isAdmin) { + return ( +
+
+
+

Access Denied

+

+ Your wallet ({selectedAccount?.address.slice(0, 8)}...) does not have admin privileges. +

+

+ Only founder and commission members can access the admin panel. +

+
+
+ ); + } + + return <>{children}; +}; \ No newline at end of file diff --git a/packages/apps/src/components/ReceiveModal.tsx b/packages/apps/src/components/ReceiveModal.tsx new file mode 100644 index 0000000..6c277a4 --- /dev/null +++ b/packages/apps/src/components/ReceiveModal.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Copy, CheckCircle, QrCode } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import QRCode from 'qrcode'; + +interface ReceiveModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ReceiveModal: React.FC = ({ isOpen, onClose }) => { + const { selectedAccount } = usePezkuwi(); + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(''); + + React.useEffect(() => { + if (selectedAccount && isOpen) { + // Generate QR code + QRCode.toDataURL(selectedAccount.address, { + width: 300, + margin: 2, + color: { + dark: '#ffffff', + light: '#0f172a' + } + }).then(setQrCodeDataUrl).catch((err) => { + if (process.env.NODE_ENV !== 'production') console.error('QR code generation failed:', err); + }); + } + }, [selectedAccount, isOpen]); + + const handleCopyAddress = async () => { + if (!selectedAccount) return; + + try { + await navigator.clipboard.writeText(selectedAccount.address); + setCopied(true); + toast({ + title: "Address Copied!", + description: "Your wallet address has been copied to clipboard", + }); + + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ + title: "Copy Failed", + description: "Failed to copy address to clipboard", + variant: "destructive", + }); + } + }; + + if (!selectedAccount) { + return null; + } + + return ( + + + + Receive Tokens + + Share this address to receive HEZ, PEZ, and other tokens + + + +
+ {/* QR Code */} +
+ {qrCodeDataUrl ? ( + QR Code + ) : ( +
+ +
+ )} +
+ + {/* Account Name */} +
+
Account Name
+
+ {selectedAccount.meta.name || 'Unnamed Account'} +
+
+ + {/* Address */} +
+
Wallet Address
+
+
+ {selectedAccount.address} +
+
+ + +
+ + {/* Warning */} +
+

+ Important: Only send PezkuwiChain compatible tokens to this address. Sending other tokens may result in permanent loss. +

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/apps/src/components/RemoveLiquidityModal.tsx b/packages/apps/src/components/RemoveLiquidityModal.tsx new file mode 100644 index 0000000..021c016 --- /dev/null +++ b/packages/apps/src/components/RemoveLiquidityModal.tsx @@ -0,0 +1,381 @@ +import React, { useState, useEffect } from 'react'; +import { X, Minus, AlertCircle, Info } from 'lucide-react'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; + +// Helper to get display name for tokens (users see HEZ not wHEZ, USDT not wUSDT) +const getDisplayTokenName = (assetId: number): string => { + if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ'; + if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; + if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; + return getAssetSymbol(assetId); +}; + +// Helper to get decimals for each asset +const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ have 12 decimals +}; + +interface RemoveLiquidityModalProps { + isOpen: boolean; + onClose: () => void; + lpPosition: { + lpTokenBalance: number; + share: number; + asset0Amount: number; + asset1Amount: number; + }; + lpTokenId: number; + asset0: number; // First asset ID in the pool + asset1: number; // Second asset ID in the pool +} + +export const RemoveLiquidityModal: React.FC = ({ + isOpen, + onClose, + lpPosition, + asset0, + asset1, +}) => { + const { api, selectedAccount } = usePezkuwi(); + const { refreshBalances } = useWallet(); + + const [percentage, setPercentage] = useState(100); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [minBalance0, setMinBalance0] = useState(0); + const [minBalance1, setMinBalance1] = useState(0); + const [maxRemovablePercentage, setMaxRemovablePercentage] = useState(100); + + // Fetch minimum balances for both assets + useEffect(() => { + if (!api || !isOpen) return; + + const fetchMinBalances = async () => { + try { + if (process.env.NODE_ENV !== 'production') console.log(`🔍 Fetching minBalances for pool: asset0=${asset0} (${getDisplayTokenName(asset0)}), asset1=${asset1} (${getDisplayTokenName(asset1)})`); + + // For wHEZ (asset ID 0), we need to fetch from assets pallet + // For native HEZ, we would need existentialDeposit from balances + // But in our pools, we only use wHEZ, wUSDT, PEZ (all wrapped assets) + + if (asset0 === ASSET_IDS.WHEZ || asset0 === 0) { + // wHEZ is an asset in the assets pallet + const assetDetails0 = await api.query.assets.asset(ASSET_IDS.WHEZ); + if (assetDetails0.isSome) { + const details0 = assetDetails0.unwrap().toJSON() as Record; + const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0)); + setMinBalance0(min0); + if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset0)} minBalance: ${min0}`); + } + } else { + // Other assets (PEZ, wUSDT, etc.) + const assetDetails0 = await api.query.assets.asset(asset0); + if (assetDetails0.isSome) { + const details0 = assetDetails0.unwrap().toJSON() as Record; + const min0 = Number(details0.minBalance) / Math.pow(10, getAssetDecimals(asset0)); + setMinBalance0(min0); + if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset0)} minBalance: ${min0}`); + } + } + + if (asset1 === ASSET_IDS.WHEZ || asset1 === 0) { + // wHEZ is an asset in the assets pallet + const assetDetails1 = await api.query.assets.asset(ASSET_IDS.WHEZ); + if (assetDetails1.isSome) { + const details1 = assetDetails1.unwrap().toJSON() as Record; + const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1)); + setMinBalance1(min1); + if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset1)} minBalance: ${min1}`); + } + } else { + // Other assets (PEZ, wUSDT, etc.) + const assetDetails1 = await api.query.assets.asset(asset1); + if (assetDetails1.isSome) { + const details1 = assetDetails1.unwrap().toJSON() as Record; + const min1 = Number(details1.minBalance) / Math.pow(10, getAssetDecimals(asset1)); + setMinBalance1(min1); + if (process.env.NODE_ENV !== 'production') console.log(`📊 ${getDisplayTokenName(asset1)} minBalance: ${min1}`); + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching minBalances:', err); + } + }; + + fetchMinBalances(); + }, [api, isOpen, asset0, asset1]); + + // Calculate maximum removable percentage based on minBalance requirements + useEffect(() => { + if (minBalance0 === 0 || minBalance1 === 0) return; + + // Calculate what percentage would leave exactly minBalance + const maxPercent0 = ((lpPosition.asset0Amount - minBalance0) / lpPosition.asset0Amount) * 100; + const maxPercent1 = ((lpPosition.asset1Amount - minBalance1) / lpPosition.asset1Amount) * 100; + + // Take the lower of the two (most restrictive) + const maxPercent = Math.min(maxPercent0, maxPercent1, 100); + + // Round down to be safe + const safeMaxPercent = Math.floor(maxPercent * 10) / 10; + + setMaxRemovablePercentage(safeMaxPercent > 0 ? safeMaxPercent : 99); + + if (process.env.NODE_ENV !== 'production') console.log(`🔒 Max removable: ${safeMaxPercent}% (asset0: ${maxPercent0.toFixed(2)}%, asset1: ${maxPercent1.toFixed(2)}%)`); + }, [minBalance0, minBalance1, lpPosition.asset0Amount, lpPosition.asset1Amount]); + + const handleRemoveLiquidity = async () => { + if (!api || !selectedAccount) return; + + setIsLoading(true); + setError(null); + + try { + // Get the signer from the extension + const injector = await web3FromAddress(selectedAccount.address); + + // Get decimals for each asset + const asset0Decimals = getAssetDecimals(asset0); + const asset1Decimals = getAssetDecimals(asset1); + + // Calculate LP tokens to remove + const lpToRemove = (lpPosition.lpTokenBalance * percentage) / 100; + const lpToRemoveBN = BigInt(Math.floor(lpToRemove * 1e12)); + + // Calculate expected token amounts (with 95% slippage tolerance) + const expectedAsset0BN = BigInt(Math.floor((lpPosition.asset0Amount * percentage) / 100 * Math.pow(10, asset0Decimals))); + const expectedAsset1BN = BigInt(Math.floor((lpPosition.asset1Amount * percentage) / 100 * Math.pow(10, asset1Decimals))); + + const minAsset0BN = (expectedAsset0BN * BigInt(95)) / BigInt(100); + const minAsset1BN = (expectedAsset1BN * BigInt(95)) / BigInt(100); + + // Remove liquidity transaction + const removeLiquidityTx = api.tx.assetConversion.removeLiquidity( + asset0, + asset1, + lpToRemoveBN.toString(), + minAsset0BN.toString(), + minAsset1BN.toString(), + selectedAccount.address + ); + + // Check if we need to unwrap wHEZ back to HEZ + const hasWHEZ = asset0 === ASSET_IDS.WHEZ || asset1 === ASSET_IDS.WHEZ; + let tx; + + if (hasWHEZ) { + // Unwrap wHEZ back to HEZ + const whezAmount = asset0 === ASSET_IDS.WHEZ ? minAsset0BN : minAsset1BN; + const unwrapTx = api.tx.tokenWrapper.unwrap(whezAmount.toString()); + // Batch transactions: removeLiquidity + unwrap + tx = api.tx.utility.batchAll([removeLiquidityTx, unwrapTx]); + } else { + // No unwrap needed for pools without wHEZ + tx = removeLiquidityTx; + } + + await tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, events }) => { + if (status.isInBlock) { + if (process.env.NODE_ENV !== 'production') console.log('Transaction in block'); + } else if (status.isFinalized) { + if (process.env.NODE_ENV !== 'production') console.log('Transaction finalized'); + + // Check for errors + const hasError = events.some(({ event }) => + api.events.system.ExtrinsicFailed.is(event) + ); + + if (hasError) { + setError('Transaction failed'); + setIsLoading(false); + } else { + setSuccess(true); + setIsLoading(false); + refreshBalances(); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } + } + } + ); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Error removing liquidity:', err); + setError(err instanceof Error ? err.message : 'Failed to remove liquidity'); + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + // Get display names for the assets + const asset0Name = getDisplayTokenName(asset0); + const asset1Name = getDisplayTokenName(asset1); + + const asset0ToReceive = (lpPosition.asset0Amount * percentage) / 100; + const asset1ToReceive = (lpPosition.asset1Amount * percentage) / 100; + + return ( +
+
+
+

Remove Liquidity

+ +
+ + {error && ( + + + {error} + + )} + + {success && ( + + Liquidity removed successfully! + + )} + + + + + Remove your liquidity to receive back your tokens.{' '} + {(asset0 === ASSET_IDS.WHEZ || asset1 === ASSET_IDS.WHEZ) && 'wHEZ will be automatically unwrapped to HEZ.'} + + + + {maxRemovablePercentage < 100 && ( + + + + Maximum removable: {maxRemovablePercentage.toFixed(1)}% - Pool must maintain minimum balance of {minBalance0.toFixed(6)} {asset0Name} and {minBalance1.toFixed(6)} {asset1Name} + + + )} + +
+ {/* Percentage Selector */} +
+
+ + {percentage}% +
+ + setPercentage(parseInt(e.target.value))} + className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + disabled={isLoading} + /> + +
+ {[25, 50, 75, 100].map((p) => { + const effectiveP = p === 100 ? Math.floor(maxRemovablePercentage) : p; + const isDisabled = p > maxRemovablePercentage; + return ( + + ); + })} +
+
+ + {/* You Will Receive */} +
+

You Will Receive

+ +
+
+

{asset0Name}

+

+ {asset0ToReceive.toFixed(4)} +

+
+ +
+ +
+
+

{asset1Name}

+

+ {asset1ToReceive.toFixed(4)} +

+
+ +
+
+ + {/* LP Token Info */} +
+
+ LP Tokens to Burn + {((lpPosition.lpTokenBalance * percentage) / 100).toFixed(4)} +
+
+ Remaining LP Tokens + + {((lpPosition.lpTokenBalance * (100 - percentage)) / 100).toFixed(4)} + +
+
+ Remaining {asset0Name} + = lpPosition.asset0Amount - minBalance0 ? 'text-yellow-400' : ''}> + {(lpPosition.asset0Amount - asset0ToReceive).toFixed(6)} (min: {minBalance0.toFixed(6)}) + +
+
+ Remaining {asset1Name} + = lpPosition.asset1Amount - minBalance1 ? 'text-yellow-400' : ''}> + {(lpPosition.asset1Amount - asset1ToReceive).toFixed(6)} (min: {minBalance1.toFixed(6)}) + +
+
+ Slippage Tolerance + 5% +
+
+ + +
+
+
+ ); +}; diff --git a/packages/apps/src/components/ReservesDashboard.tsx b/packages/apps/src/components/ReservesDashboard.tsx new file mode 100644 index 0000000..d724965 --- /dev/null +++ b/packages/apps/src/components/ReservesDashboard.tsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { DollarSign, TrendingUp, Shield, AlertTriangle, RefreshCw, ExternalLink } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { getWUSDTTotalSupply, checkReserveHealth, formatWUSDT } from '@pezkuwi/lib/usdt'; +import { MultisigMembers } from './MultisigMembers'; + +interface ReservesDashboardProps { + specificAddresses?: Record; + offChainReserveAmount?: number; // Manual input for MVP +} + +export const ReservesDashboard: React.FC = ({ + specificAddresses = {}, + offChainReserveAmount = 0, +}) => { + const { api, isApiReady } = usePezkuwi(); + + const [wusdtSupply, setWusdtSupply] = useState(0); + const [offChainReserve, setOffChainReserve] = useState(offChainReserveAmount); + const [collateralRatio, setCollateralRatio] = useState(0); + const [isHealthy, setIsHealthy] = useState(true); + const [loading, setLoading] = useState(true); + const [lastUpdate, setLastUpdate] = useState(new Date()); + + // Fetch reserve data + const fetchReserveData = async () => { + if (!api || !isApiReady) return; + + setLoading(true); + try { + const supply = await getWUSDTTotalSupply(api); + setWusdtSupply(supply); + + const health = await checkReserveHealth(api, offChainReserve); + setCollateralRatio(health.collateralRatio); + setIsHealthy(health.isHealthy); + setLastUpdate(new Date()); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Error fetching reserve data:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchReserveData(); + + // Auto-refresh every 30 seconds + const interval = setInterval(fetchReserveData, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, isApiReady, offChainReserve]); + + + const getHealthColor = () => { + if (collateralRatio >= 105) return 'text-green-500'; + if (collateralRatio >= 100) return 'text-yellow-500'; + return 'text-red-500'; + }; + + const getHealthStatus = () => { + if (collateralRatio >= 105) return 'Healthy'; + if (collateralRatio >= 100) return 'Warning'; + return 'Critical'; + }; + + return ( +
+ {/* Header */} +
+
+

+ + USDT Reserves Dashboard +

+

Real-time reserve status and multisig info

+
+ +
+ + {/* Key Metrics */} +
+ {/* Total Supply */} + +
+
+

Total wUSDT Supply

+

+ ${formatWUSDT(wusdtSupply)} +

+

On-chain (Assets pallet)

+
+ +
+
+ + {/* Off-chain Reserve */} + +
+
+

Off-chain USDT Reserve

+

+ ${formatWUSDT(offChainReserve)} +

+
+ setOffChainReserve(parseFloat(e.target.value) || 0)} + className="w-24 text-xs bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white" + placeholder="Amount" + /> + +
+
+ +
+
+ + {/* Collateral Ratio */} + +
+
+

Collateral Ratio

+

+ {collateralRatio.toFixed(2)}% +

+
+ + {getHealthStatus()} + +
+
+ +
+
+
+ + {/* Health Alert */} + {!isHealthy && ( + + + +

Under-collateralized!

+

+ Reserve ratio is below 100%. Off-chain USDT reserves ({formatWUSDT(offChainReserve)}) + are less than on-chain wUSDT supply ({formatWUSDT(wusdtSupply)}). +

+
+
+ )} + + {/* Tabs */} + + + Overview + Multisig + Proof of Reserves + + + {/* Overview Tab */} + + +

Reserve Details

+ +
+
+ On-chain wUSDT + ${formatWUSDT(wusdtSupply)} +
+ +
+ Off-chain USDT + ${formatWUSDT(offChainReserve)} +
+ +
+ Backing Ratio + + {collateralRatio.toFixed(2)}% + +
+ +
+ Status + + {getHealthStatus()} + +
+ +
+ Last Updated + {lastUpdate.toLocaleTimeString()} +
+
+ + + + +

1:1 Backing

+

+ Every wUSDT is backed by real USDT held in the multisig treasury. + Target ratio: ≥100% (ideally 105% for safety buffer). +

+
+
+
+
+ + {/* Multisig Tab */} + + + + + {/* Proof of Reserves Tab */} + + +

Proof of Reserves

+ +
+ + + +

How to Verify Reserves:

+
    +
  1. Check on-chain wUSDT supply via Pezkuwi Explorer
  2. +
  3. Verify multisig account balance (if reserves on-chain)
  4. +
  5. Compare with off-chain treasury (bank/exchange account)
  6. +
  7. Ensure ratio ≥ 100%
  8. +
+
+
+ + + +
+

+ Note: Off-chain Reserves +

+

+ In this MVP implementation, off-chain USDT reserves are manually reported. + For full decentralization, consider integrating with oracle services or + using XCM bridge for on-chain verification. +

+
+
+
+
+
+
+ ); +}; diff --git a/packages/apps/src/components/RewardDistribution.tsx b/packages/apps/src/components/RewardDistribution.tsx new file mode 100644 index 0000000..3365fcf --- /dev/null +++ b/packages/apps/src/components/RewardDistribution.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { Gift, Calendar, Users, Timer, DollarSign } from 'lucide-react'; + +const RewardDistribution: React.FC = () => { + const [currentEpoch, setCurrentEpoch] = useState(1); + const [trustScoreInput, setTrustScoreInput] = useState(500); + const [totalParticipants, setTotalParticipants] = useState(1000); + const [totalTrustScore, setTotalTrustScore] = useState(500000); + + const epochRewardPool = 1000000; // 1M PEZ per epoch + const parliamentaryAllocation = epochRewardPool * 0.1; // 10% for NFT holders + const trustScorePool = epochRewardPool * 0.9; // 90% for trust score rewards + const rewardPerTrustPoint = trustScorePool / totalTrustScore; + const userReward = trustScoreInput * rewardPerTrustPoint; + const nftRewardPerHolder = parliamentaryAllocation / 201; + + const epochPhases = [ + { name: 'Active', duration: '30 days', blocks: 432000, status: 'current' }, + { name: 'Claim Period', duration: '7 days', blocks: 100800, status: 'upcoming' }, + { name: 'Closed', duration: 'Permanent', blocks: 0, status: 'final' } + ]; + + return ( +
+
+
+

+ Reward Distribution System +

+

+ Monthly epoch-based rewards distributed by trust score and NFT holdings +

+
+ +
+ PezkuwiChain Logo +
+ +
+ {/* Epoch Timeline */} +
+
+ +

Epoch Timeline

+
+ +
+
+ Current Epoch + #{currentEpoch} +
+ setCurrentEpoch(parseInt(e.target.value))} + className="w-full" + /> +
+ +
+ {epochPhases.map((phase, index) => ( +
+
+
+

+ {phase.name} +

+
+ + {phase.duration} +
+
+ {phase.blocks > 0 && ( +
+ {phase.blocks.toLocaleString()} blocks +
+ )} +
+ {index < epochPhases.length - 1 && ( +
+ )} +
+ ))} +
+ +
+
+
Epoch Start Block
+
+ #{((currentEpoch - 1) * 432000).toLocaleString()} +
+
+
+
Claim Deadline Block
+
+ #{((currentEpoch * 432000) + 100800).toLocaleString()} +
+
+
+
+ + {/* Reward Pool Info */} +
+
+
+ +

Epoch Pool

+
+ +
+ {epochRewardPool.toLocaleString()} PEZ +
+ +
+
+ Trust Score Pool + 90% +
+
+ Parliamentary NFTs + 10% +
+
+
+ +
+
+ +

NFT Rewards

+
+ +
+
+ Total NFTs + 201 +
+
+ Per NFT Reward + + {Math.floor(nftRewardPerHolder).toLocaleString()} PEZ + +
+
+
Auto-distributed
+
No claim required
+
+
+
+
+
+ + {/* Reward Calculator */} +
+

+ + Reward Calculator +

+ +
+
+ + setTrustScoreInput(parseInt(e.target.value) || 0)} + className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none" + /> +
+ +
+ + setTotalParticipants(parseInt(e.target.value) || 1)} + className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none" + /> +
+ +
+ + setTotalTrustScore(parseInt(e.target.value) || 1)} + className="w-full px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none" + /> +
+
+ +
+
+
+
Reward per Trust Point
+
+ {rewardPerTrustPoint.toFixed(4)} PEZ +
+
+
+
Your Share
+
+ {((trustScoreInput / totalTrustScore) * 100).toFixed(3)}% +
+
+
+
Estimated Reward
+
+ {Math.floor(userReward).toLocaleString()} PEZ +
+
+
+
+
+
+
+ ); +}; + +export default RewardDistribution; \ No newline at end of file diff --git a/packages/apps/src/components/RouteGuards.tsx b/packages/apps/src/components/RouteGuards.tsx new file mode 100644 index 0000000..e571d4d --- /dev/null +++ b/packages/apps/src/components/RouteGuards.tsx @@ -0,0 +1,466 @@ +// ======================================== +// Route Guard Components +// ======================================== +// Protected route wrappers that check user permissions + +import React, { useEffect, useState, ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { + checkCitizenStatus, + checkValidatorStatus, + checkEducatorRole, + checkModeratorRole, +} from '@pezkuwi/lib/guards'; +import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, AlertCircle, Users, GraduationCap, Shield } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface RouteGuardProps { + children: ReactNode; + fallbackPath?: string; +} + +// ======================================== +// LOADING COMPONENT +// ======================================== + +const LoadingGuard: React.FC = () => { + return ( +
+ + + +

Checking permissions...

+
+
+
+ ); +}; + +// ======================================== +// CITIZEN ROUTE GUARD +// ======================================== + +/** + * CitizenRoute - Requires approved KYC (citizenship) + * Use for: Voting, Education, Elections, etc. + * + * @example + * + * + * + * } /> + */ +export const CitizenRoute: React.FC = ({ + children, + fallbackPath = '/be-citizen', +}) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const {} = useAuth(); + const [isCitizen, setIsCitizen] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkPermission = async () => { + if (!isApiReady || !api) { + setLoading(true); + return; + } + + if (!selectedAccount?.address) { + setIsCitizen(false); + setLoading(false); + return; + } + + try { + const citizenStatus = await checkCitizenStatus(api, selectedAccount.address); + setIsCitizen(citizenStatus); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Citizen check failed:', error); + setIsCitizen(false); + } finally { + setLoading(false); + } + }; + + checkPermission(); + }, [api, isApiReady, selectedAccount]); + + // Loading state + if (loading || !isApiReady) { + return ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ( +
+ + +
+ +

Wallet Not Connected

+

+ Please connect your Pezkuwi wallet to access this feature. +

+ +
+
+
+
+ ); + } + + // Not a citizen + if (isCitizen === false) { + return ; + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// VALIDATOR ROUTE GUARD +// ======================================== + +/** + * ValidatorRoute - Requires validator pool membership + * Use for: Validator pool dashboard, validator settings + * + * @example + * + * + * + * } /> + */ +export const ValidatorRoute: React.FC = ({ + children, + fallbackPath = '/staking', +}) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const [isValidator, setIsValidator] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkPermission = async () => { + if (!isApiReady || !api) { + setLoading(true); + return; + } + + if (!selectedAccount?.address) { + setIsValidator(false); + setLoading(false); + return; + } + + try { + const validatorStatus = await checkValidatorStatus(api, selectedAccount.address); + setIsValidator(validatorStatus); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Validator check failed:', error); + setIsValidator(false); + } finally { + setLoading(false); + } + }; + + checkPermission(); + }, [api, isApiReady, selectedAccount]); + + // Loading state + if (loading || !isApiReady) { + return ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not in validator pool + if (isValidator === false) { + return ( +
+ + + + + + Validator Access Required + You must be registered in the Validator Pool to access this feature. +
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// EDUCATOR ROUTE GUARD +// ======================================== + +/** + * EducatorRoute - Requires educator Tiki role + * Use for: Creating courses in Perwerde (Education platform) + * + * @example + * + * + * + * } /> + */ +export const EducatorRoute: React.FC = ({ + children, + fallbackPath = '/education', +}) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const [isEducator, setIsEducator] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkPermission = async () => { + if (!isApiReady || !api) { + setLoading(true); + return; + } + + if (!selectedAccount?.address) { + setIsEducator(false); + setLoading(false); + return; + } + + try { + const educatorStatus = await checkEducatorRole(api, selectedAccount.address); + setIsEducator(educatorStatus); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Educator check failed:', error); + setIsEducator(false); + } finally { + setLoading(false); + } + }; + + checkPermission(); + }, [api, isApiReady, selectedAccount]); + + // Loading state + if (loading || !isApiReady) { + return ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not an educator + if (isEducator === false) { + return ( +
+ + + + + + Educator Role Required + You need one of these Tiki roles to create courses: +
    +
  • Perwerdekar (Educator)
  • +
  • Mamoste (Teacher)
  • +
  • WezireCand (Education Minister)
  • +
  • Rewsenbîr (Intellectual)
  • +
+
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// MODERATOR ROUTE GUARD +// ======================================== + +/** + * ModeratorRoute - Requires moderator Tiki role + * Use for: Forum moderation, governance moderation + * + * @example + * + * + * + * } /> + */ +export const ModeratorRoute: React.FC = ({ + children, + fallbackPath = '/', +}) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const [isModerator, setIsModerator] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkPermission = async () => { + if (!isApiReady || !api) { + setLoading(true); + return; + } + + if (!selectedAccount?.address) { + setIsModerator(false); + setLoading(false); + return; + } + + try { + const moderatorStatus = await checkModeratorRole(api, selectedAccount.address); + setIsModerator(moderatorStatus); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Moderator check failed:', error); + setIsModerator(false); + } finally { + setLoading(false); + } + }; + + checkPermission(); + }, [api, isApiReady, selectedAccount]); + + // Loading state + if (loading || !isApiReady) { + return ; + } + + // Not connected to wallet + if (!selectedAccount) { + return ; + } + + // Not a moderator + if (isModerator === false) { + return ( +
+ + + + + + Moderator Access Required + You need moderator privileges to access this feature. +
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; + +// ======================================== +// ADMIN ROUTE GUARD (Supabase-based) +// ======================================== + +/** + * AdminRoute - Requires Supabase admin role + * Use for: Admin panel, system settings + * Note: This is separate from blockchain permissions + */ +export const AdminRoute: React.FC = ({ + children, + fallbackPath = '/', +}) => { + const { user, isAdmin, loading } = useAuth(); + + // Loading state + if (loading) { + return ; + } + + // Not logged in + if (!user) { + return ; + } + + // Not admin + if (!isAdmin) { + return ( +
+ + + + + + Admin Access Required + You do not have permission to access the admin panel. +
+ +
+
+
+
+
+
+ ); + } + + // Authorized + return <>{children}; +}; diff --git a/packages/apps/src/components/TeamSection.tsx b/packages/apps/src/components/TeamSection.tsx new file mode 100644 index 0000000..e070e70 --- /dev/null +++ b/packages/apps/src/components/TeamSection.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Card, CardContent } from './ui/card'; +import { Badge } from './ui/badge'; +import { Users } from 'lucide-react'; + +interface TeamMember { + name: string; + role: string; + description: string; + image: string; +} + +const TeamSection: React.FC = () => { + const teamMembers: TeamMember[] = [ + { + name: "Satoshi Qazi Muhammed", + role: "Chief Architect", + description: "Blockchain visionary and protocol designer", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358016604_9ae228b4.webp" + }, + { + name: "Abdurrahman Qasimlo", + role: "Governance Lead", + description: "Democratic systems and consensus mechanisms", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358018357_f19e128d.webp" + }, + { + name: "Abdusselam Barzani", + role: "Protocol Engineer", + description: "Core protocol development and optimization", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358020150_1ea35457.webp" + }, + { + name: "Ihsan Nuri", + role: "Security Advisor", + description: "Cryptography and network security expert", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358021872_362f1214.webp" + }, + { + name: "Seyh Said", + role: "Community Director", + description: "Ecosystem growth and community relations", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358023648_4bb8f4c7.webp" + }, + { + name: "Seyyid Riza", + role: "Treasury Manager", + description: "Economic models and treasury operations", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358025533_d9df77a9.webp" + }, + { + name: "Beritan", + role: "Developer Relations", + description: "Technical documentation and developer support", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358027281_9254657a.webp" + }, + { + name: "Mashuk Xaznevi", + role: "Research Lead", + description: "Blockchain research and innovation", + image: "https://d64gsuwffb70l.cloudfront.net/68ec477a0a2fa844d6f9df15_1760358029000_3ffc04bc.webp" + } + ]; + + return ( +
+
+
+ + + Our Team + +

+ Meet the Visionaries +

+

+ A dedicated team of blockchain experts and governance specialists building the future of decentralized democracy +

+
+ +
+ {teamMembers.map((member, index) => ( + + +
+
+ {member.name} +
+

+ {member.name} +

+ + {member.role} + +

+ {member.description} +

+
+
+
+ ))} +
+
+
+ ); +}; + +export default TeamSection; \ No newline at end of file diff --git a/packages/apps/src/components/TokenSwap.tsx b/packages/apps/src/components/TokenSwap.tsx new file mode 100644 index 0000000..d8c1764 --- /dev/null +++ b/packages/apps/src/components/TokenSwap.tsx @@ -0,0 +1,1240 @@ +import React, { useState, useEffect } from 'react'; +import { ArrowDownUp, Settings, TrendingUp, Clock, AlertCircle, Info, AlertTriangle } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { ASSET_IDS, formatBalance, parseAmount } from '@pezkuwi/lib/wallet'; +import { useToast } from '@/hooks/use-toast'; +import { KurdistanSun } from './KurdistanSun'; +import { PriceChart } from './trading/PriceChart'; + +// Available tokens for swap +const AVAILABLE_TOKENS = [ + { symbol: 'HEZ', emoji: '🟡', assetId: 0, name: 'HEZ', badge: true, displaySymbol: 'HEZ' }, + { symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', badge: true, displaySymbol: 'PEZ' }, + { symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', badge: true, displaySymbol: 'USDT' }, +] as const; + +const TokenSwap = () => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { balances, refreshBalances } = useWallet(); + const { toast } = useToast(); + + const [fromToken, setFromToken] = useState('PEZ'); + const [toToken, setToToken] = useState('HEZ'); + const [fromAmount, setFromAmount] = useState(''); + const [slippage, setSlippage] = useState('0.5'); + const [showSettings, setShowSettings] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [isSwapping, setIsSwapping] = useState(false); + + // DEX availability check + const [isDexAvailable, setIsDexAvailable] = useState(false); + + // Exchange rate and loading states + const [exchangeRate, setExchangeRate] = useState(0); + const [isLoadingRate, setIsLoadingRate] = useState(false); + + // Get balances from wallet context + if (process.env.NODE_ENV !== 'production') console.log('🔍 TokenSwap balances from context:', balances); + if (process.env.NODE_ENV !== 'production') console.log('🔍 fromToken:', fromToken, 'toToken:', toToken); + const fromBalance = balances[fromToken as keyof typeof balances]; + const toBalance = balances[toToken as keyof typeof balances]; + if (process.env.NODE_ENV !== 'production') console.log('🔍 Final balances:', { fromBalance, toBalance }); + + // Liquidity pool data + interface LiquidityPool { + key: string; + data: unknown; + tvl: number; + } + const [liquidityPools, setLiquidityPools] = useState([]); + const [isLoadingPools, setIsLoadingPools] = useState(false); + + // Transaction history + interface SwapTransaction { + blockNumber: number; + timestamp: number; + from: string; + fromToken: string; + fromAmount: string; + toToken: string; + toAmount: string; + txHash: string; + } + const [swapHistory, setSwapHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // Pool reserves for AMM calculation + const [poolReserves, setPoolReserves] = useState<{ reserve0: number; reserve1: number; asset0: number; asset1: number } | null>(null); + + // Helper: Get display name for token (USDT instead of wUSDT) + const getTokenDisplayName = (tokenSymbol: string) => { + const token = AVAILABLE_TOKENS.find(t => t.symbol === tokenSymbol); + return token?.displaySymbol || tokenSymbol; + }; + + // Check if user has insufficient balance + const hasInsufficientBalance = React.useMemo(() => { + const fromAmountNum = parseFloat(fromAmount || '0'); + const fromBalanceNum = parseFloat(fromBalance?.toString() || '0'); + return fromAmountNum > 0 && fromAmountNum > fromBalanceNum; + }, [fromAmount, fromBalance]); + + // Calculate toAmount and price impact using AMM constant product formula + const swapCalculations = React.useMemo(() => { + if (!fromAmount || !poolReserves || parseFloat(fromAmount) <= 0) { + return { toAmount: '', priceImpact: 0, minimumReceived: '', lpFee: '' }; + } + + const amountIn = parseFloat(fromAmount); + const { reserve0, reserve1, asset0 } = poolReserves; + + // Determine which reserve is input and which is output + const fromAssetId = fromToken === 'HEZ' ? 0 : ASSET_IDS[fromToken as keyof typeof ASSET_IDS]; + const isAsset0ToAsset1 = fromAssetId === asset0; + + const reserveIn = isAsset0ToAsset1 ? reserve0 : reserve1; + const reserveOut = isAsset0ToAsset1 ? reserve1 : reserve0; + + // Uniswap V2 AMM formula (matches Substrate runtime exactly) + // Runtime: amount_in_with_fee = amount_in * (1000 - LPFee) = amount_in * 970 + // LPFee = 30 (3% fee, not 0.3%!) + // Formula: amountOut = (amountIn * 970 * reserveOut) / (reserveIn * 1000 + amountIn * 970) + const LP_FEE = 30; // 3% fee + const amountInWithFee = amountIn * (1000 - LP_FEE); // = amountIn * 970 + const numerator = amountInWithFee * reserveOut; + const denominator = reserveIn * 1000 + amountInWithFee; + const amountOut = numerator / denominator; + + // Calculate price impact (like Uniswap) + // Price impact = (amount_in / reserve_in) / (1 + amount_in / reserve_in) * 100 + const priceImpact = (amountIn / (reserveIn + amountIn)) * 100; + + // Calculate LP fee amount + const lpFeeAmount = (amountIn * (LP_FEE / 1000)).toFixed(4); + + // Calculate minimum received with slippage + const minReceived = (amountOut * (1 - parseFloat(slippage) / 100)).toFixed(4); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Uniswap V2 AMM:', { + amountIn, + amountInWithFee, + reserveIn, + reserveOut, + numerator, + denominator, + amountOut, + priceImpact: priceImpact.toFixed(2) + '%', + lpFeeAmount, + minReceived, + feePercent: LP_FEE / 10 + '%' + }); + + return { + toAmount: amountOut.toFixed(4), + priceImpact, + minimumReceived: minReceived, + lpFee: lpFeeAmount + }; + }, [fromAmount, poolReserves, fromToken, slippage]); + + const { toAmount, priceImpact, minimumReceived, lpFee } = swapCalculations; + + // Check if AssetConversion pallet is available + useEffect(() => { + if (process.env.NODE_ENV !== 'production') console.log('🔍 Checking DEX availability...', { api: !!api, isApiReady }); + if (api && isApiReady) { + const hasAssetConversion = api.tx.assetConversion !== undefined; + if (process.env.NODE_ENV !== 'production') console.log('🔍 AssetConversion pallet check:', hasAssetConversion); + setIsDexAvailable(hasAssetConversion); + + if (!hasAssetConversion) { + if (process.env.NODE_ENV !== 'production') console.warn('⚠️ AssetConversion pallet not available in runtime'); + } else { + if (process.env.NODE_ENV !== 'production') console.log('✅ AssetConversion pallet is available!'); + } + } + }, [api, isApiReady]); + + // Fetch exchange rate from AssetConversion pool + // Always use wHEZ/PEZ pool (the only valid pool) + useEffect(() => { + const fetchExchangeRate = async () => { + if (process.env.NODE_ENV !== 'production') console.log('🔍 fetchExchangeRate check:', { api: !!api, isApiReady, isDexAvailable, fromToken, toToken }); + + if (!api || !isApiReady || !isDexAvailable) { + if (process.env.NODE_ENV !== 'production') console.log('⚠️ Skipping fetchExchangeRate:', { api: !!api, isApiReady, isDexAvailable }); + return; + } + + if (process.env.NODE_ENV !== 'production') console.log('✅ Starting fetchExchangeRate...'); + setIsLoadingRate(true); + try { + // Map user-selected tokens to actual pool assets + // HEZ → wHEZ (Asset 0) behind the scenes + const getPoolAssetId = (token: string) => { + if (token === 'HEZ') return 0; // wHEZ + if (token === 'PEZ') return 1; + if (token === 'USDT') return 1000; + return ASSET_IDS[token as keyof typeof ASSET_IDS]; + }; + + const fromAssetId = getPoolAssetId(fromToken); + const toAssetId = getPoolAssetId(toToken); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Looking for pool:', { fromToken, toToken, fromAssetId, toAssetId }); + + // IMPORTANT: Pool ID must be sorted (smaller asset ID first) + const [asset1, asset2] = fromAssetId < toAssetId + ? [fromAssetId, toAssetId] + : [toAssetId, fromAssetId]; + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Sorted pool assets:', { asset1, asset2 }); + + // Create pool asset tuple [asset1, asset2] - must be sorted! + const poolAssets = [ + { NativeOrAsset: { Asset: asset1 } }, + { NativeOrAsset: { Asset: asset2 } } + ]; + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool query with:', poolAssets); + + // Query pool from AssetConversion pallet + const poolInfo = await api.query.assetConversion.pools(poolAssets); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool query result:', poolInfo.toHuman()); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool isEmpty?', poolInfo.isEmpty, 'exists?', !poolInfo.isEmpty); + + if (poolInfo && !poolInfo.isEmpty) { + const pool = poolInfo.toJSON() as Record; + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool data:', pool); + + try { + // New pallet version: reserves are stored in pool account balances + // AccountIdConverter implementation in substrate: + // blake2_256(&Encode::encode(&(PalletId, PoolId))[..]) + if (process.env.NODE_ENV !== 'production') console.log('🔍 Deriving pool account using AccountIdConverter...'); + const { stringToU8a } = await import('@pezkuwi/util'); + const { blake2AsU8a } = await import('@pezkuwi/util-crypto'); + + // PalletId for AssetConversion: "py/ascon" (8 bytes) + const PALLET_ID = stringToU8a('py/ascon'); + + // Create PoolId tuple (u32, u32) + const poolId = api.createType('(u32, u32)', [asset1, asset2]); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool ID:', poolId.toHuman()); + + // Create (PalletId, PoolId) tuple: ([u8; 8], (u32, u32)) + const palletIdType = api.createType('[u8; 8]', PALLET_ID); + const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolId]); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Full tuple encoded length:', fullTuple.toU8a().length); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Full tuple bytes:', Array.from(fullTuple.toU8a())); + + // Hash the SCALE-encoded tuple + const accountHash = blake2AsU8a(fullTuple.toU8a(), 256); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Account hash:', Array.from(accountHash).slice(0, 8)); + + const poolAccountId = api.createType('AccountId32', accountHash); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Pool AccountId (NEW METHOD):', poolAccountId.toString()); + + // Query pool account's asset balances + if (process.env.NODE_ENV !== 'production') console.log('🔍 Querying reserves for asset', asset1, 'and', asset2); + const reserve0Query = await api.query.assets.account(asset1, poolAccountId); + const reserve1Query = await api.query.assets.account(asset2, poolAccountId); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve0 query result:', reserve0Query.toHuman()); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve1 query result:', reserve1Query.toHuman()); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve0 isEmpty?', reserve0Query.isEmpty); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve1 isEmpty?', reserve1Query.isEmpty); + + const reserve0Data = reserve0Query.toJSON() as Record; + const reserve1Data = reserve1Query.toJSON() as Record; + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve0 JSON:', reserve0Data); + if (process.env.NODE_ENV !== 'production') console.log('🔍 Reserve1 JSON:', reserve1Data); + + if (reserve0Data && reserve1Data && reserve0Data.balance && reserve1Data.balance) { + // Parse hex string balances to BigInt, then to number + const balance0Hex = reserve0Data.balance.toString(); + const balance1Hex = reserve1Data.balance.toString(); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Raw hex balances:', { balance0Hex, balance1Hex }); + + // Use correct decimals for each asset + // asset1=0 (wHEZ): 12 decimals + // asset1=1 (PEZ): 12 decimals + // asset2=1000 (wUSDT): 6 decimals + const decimals0 = asset1 === 1000 ? 6 : 12; // asset1 is the smaller ID + const decimals1 = asset2 === 1000 ? 6 : 12; // asset2 is the larger ID + + const reserve0 = Number(BigInt(balance0Hex)) / (10 ** decimals0); + const reserve1 = Number(BigInt(balance1Hex)) / (10 ** decimals1); + + if (process.env.NODE_ENV !== 'production') console.log('✅ Reserves found:', { reserve0, reserve1, decimals0, decimals1 }); + + // Store pool reserves for AMM calculation + setPoolReserves({ + reserve0, + reserve1, + asset0: asset1, // Sorted pool always has asset1 < asset2 + asset1: asset2 + }); + + // Also calculate simple exchange rate for display + const rate = fromAssetId === asset1 + ? reserve1 / reserve0 // from asset1 to asset2 + : reserve0 / reserve1; // from asset2 to asset1 + + if (process.env.NODE_ENV !== 'production') console.log('✅ Exchange rate:', rate, 'direction:', fromAssetId === asset1 ? 'asset1→asset2' : 'asset2→asset1'); + setExchangeRate(rate); + } else { + if (process.env.NODE_ENV !== 'production') console.warn('⚠️ Pool has no reserves - reserve0Data:', reserve0Data, 'reserve1Data:', reserve1Data); + setExchangeRate(0); + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('❌ Error deriving pool account:', err); + setExchangeRate(0); + } + } else { + if (process.env.NODE_ENV !== 'production') console.warn('No liquidity pool found for this pair'); + setExchangeRate(0); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch exchange rate:', error); + setExchangeRate(0); + } finally { + setIsLoadingRate(false); + } + }; + + fetchExchangeRate(); + }, [api, isApiReady, isDexAvailable, fromToken, toToken]); + + // Fetch liquidity pools + useEffect(() => { + const fetchLiquidityPools = async () => { + if (!api || !isApiReady || !isDexAvailable) { + return; + } + + setIsLoadingPools(true); + try { + // Query all pools from AssetConversion pallet + const poolsEntries = await api.query.assetConversion.pools.entries(); + + if (poolsEntries && poolsEntries.length > 0) { + const pools = poolsEntries.map(([key, value]: [unknown, unknown]) => { + const poolData = value.toJSON(); + const poolKey = key.toHuman(); + + // Calculate TVL from reserves + const tvl = poolData && poolData[0] && poolData[1] + ? ((parseFloat(poolData[0]) + parseFloat(poolData[1])) / 1e12).toFixed(2) + : '0'; + + // Parse asset IDs from pool key + const assets = poolKey?.[0] || []; + //const _asset1 = assets[0]?.NativeOrAsset?.Asset || '?'; + const asset2 = assets[1]?.NativeOrAsset?.Asset || '?'; + + return { + pool: `Asset ${asset1} / Asset ${asset2}`, + tvl: `$${tvl}M`, + apr: 'TBD', // Requires historical data + volume: 'TBD', // Requires event indexing + }; + }); + + setLiquidityPools(pools.slice(0, 3)); + } else { + setLiquidityPools([]); + } + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch liquidity pools:', error); + setLiquidityPools([]); + } finally { + setIsLoadingPools(false); + } + }; + + fetchLiquidityPools(); + }, [api, isApiReady, isDexAvailable]); + + // Fetch swap transaction history + useEffect(() => { + const fetchSwapHistory = async () => { + if (!api || !isApiReady || !isDexAvailable || !selectedAccount) { + return; + } + + setIsLoadingHistory(true); + try { + // Get recent finalized blocks (last 100 blocks) + const finalizedHead = await api.rpc.chain.getFinalizedHead(); + const finalizedBlock = await api.rpc.chain.getBlock(finalizedHead); + const currentBlockNumber = finalizedBlock.block.header.number.toNumber(); + + const startBlock = Math.max(0, currentBlockNumber - 100); + + if (process.env.NODE_ENV !== 'production') console.log('🔍 Fetching swap history from block', startBlock, 'to', currentBlockNumber); + + const transactions: SwapTransaction[] = []; + + // Query block by block for SwapExecuted events + for (let blockNum = currentBlockNumber; blockNum >= startBlock && transactions.length < 10; blockNum--) { + try { + const blockHash = await api.rpc.chain.getBlockHash(blockNum); + const apiAt = await api.at(blockHash); + const events = await apiAt.query.system.events(); + //const block = await api.rpc.chain.getBlock(blockHash); + const timestamp = Date.now() - ((currentBlockNumber - blockNum) * 6000); // Estimate 6s per block + + events.forEach((record: { event: { data: unknown[] } }) => { + const { event } = record; + + // Check for AssetConversion::SwapExecuted event + if (api.events.assetConversion?.SwapExecuted?.is(event)) { + // SwapExecuted has 5 fields: (who, send_to, amountIn, amountOut, path) + const [who, , amountIn, amountOut, path] = event.data; + + // Parse path to get token symbols - path is Vec + let fromAssetId = 0; + let toAssetId = 0; + + try { + // Path structure is: [[assetId, amount], [assetId, amount]] + const pathArray = path.toJSON ? path.toJSON() : path; + + if (Array.isArray(pathArray) && pathArray.length >= 2) { + // Extract asset IDs from path + const asset0 = pathArray[0]; + //const _asset1 = pathArray[1]; + + // Each element is a tuple where index 0 is the asset ID + if (Array.isArray(asset0) && asset0.length >= 1) { + fromAssetId = typeof asset0[0] === 'number' ? asset0[0] : parseInt(asset0[0]) || 0; + } + if (Array.isArray(asset1) && asset1.length >= 1) { + toAssetId = typeof asset1[0] === 'number' ? asset1[0] : parseInt(asset1[0]) || 0; + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to parse swap path:', err); + } + + const fromTokenSymbol = fromAssetId === 0 ? 'wHEZ' : fromAssetId === 1 ? 'PEZ' : fromAssetId === 1000 ? 'USDT' : `Asset${fromAssetId}`; + const toTokenSymbol = toAssetId === 0 ? 'wHEZ' : toAssetId === 1 ? 'PEZ' : toAssetId === 1000 ? 'USDT' : `Asset${toAssetId}`; + + // Only show transactions from current user + if (who.toString() === selectedAccount.address) { + transactions.push({ + blockNumber: blockNum, + timestamp, + from: who.toString(), + fromToken: fromTokenSymbol === 'wHEZ' ? 'HEZ' : fromTokenSymbol, + fromAmount: formatBalance(amountIn.toString()), + toToken: toTokenSymbol === 'wHEZ' ? 'HEZ' : toTokenSymbol, + toAmount: formatBalance(amountOut.toString()), + txHash: blockHash.toHex() + }); + } + } + }); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn(`Failed to fetch block ${blockNum}:`, err); + } + } + + if (process.env.NODE_ENV !== 'production') console.log('✅ Swap history fetched:', transactions.length, 'transactions'); + setSwapHistory(transactions.slice(0, 10)); // Show max 10 + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch swap history:', error); + setSwapHistory([]); + } finally { + setIsLoadingHistory(false); + } + }; + + fetchSwapHistory(); + }, [api, isApiReady, isDexAvailable, selectedAccount]); + + const handleSwap = () => { + setFromToken(toToken); + setToToken(fromToken); + setFromAmount(''); + }; + + const handleConfirmSwap = async () => { + if (!api || !selectedAccount) { + toast({ + title: 'Error', + description: 'Please connect your wallet', + variant: 'destructive', + }); + return; + } + + if (!isDexAvailable) { + toast({ + title: 'DEX Not Available', + description: 'AssetConversion pallet is not enabled in runtime', + variant: 'destructive', + }); + return; + } + + if (!exchangeRate || exchangeRate === 0) { + toast({ + title: 'Error', + description: 'No liquidity pool available for this pair', + variant: 'destructive', + }); + return; + } + + // ✅ BALANCE VALIDATION - Check if user has sufficient balance + const fromAmountNum = parseFloat(fromAmount); + const fromBalanceNum = parseFloat(fromBalance?.toString() || '0'); + + if (fromAmountNum > fromBalanceNum) { + toast({ + title: 'Insufficient Balance', + description: `You only have ${fromBalanceNum.toFixed(4)} ${getTokenDisplayName(fromToken)}. Cannot swap ${fromAmountNum} ${getTokenDisplayName(fromToken)}.`, + variant: 'destructive', + }); + return; + } + + setIsSwapping(true); + setShowConfirm(false); // Close dialog before transaction starts + try { + // Get correct decimals for each token + const getTokenDecimals = (token: string) => { + if (token === 'USDT') return 6; // USDT has 6 decimals + return 12; // HEZ, wHEZ, PEZ all have 12 decimals + }; + + const fromDecimals = getTokenDecimals(fromToken); + const toDecimals = getTokenDecimals(toToken); + + const amountIn = parseAmount(fromAmount, fromDecimals); + const minAmountOut = parseAmount( + (parseFloat(toAmount) * (1 - parseFloat(slippage) / 100)).toString(), + toDecimals + ); + + if (process.env.NODE_ENV !== 'production') console.log('💰 Swap amounts:', { + fromToken, + toToken, + fromAmount, + toAmount, + fromDecimals, + toDecimals, + amountIn: amountIn.toString(), + minAmountOut: minAmountOut.toString() + }); + + // Get signer from extension + const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + // Build transaction based on token types + let tx; + + if (fromToken === 'HEZ' && toToken === 'PEZ') { + // HEZ → PEZ: wrap(HEZ→wHEZ) then swap(wHEZ→PEZ) + const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString()); + // AssetKind = u32, so swap path is just [0, 1] + const swapPath = [0, 1]; // wHEZ → PEZ + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + tx = api.tx.utility.batchAll([wrapTx, swapTx]); + + } else if (fromToken === 'PEZ' && toToken === 'HEZ') { + // PEZ → HEZ: swap(PEZ→wHEZ) then unwrap(wHEZ→HEZ) + // AssetKind = u32, so swap path is just [1, 0] + const swapPath = [1, 0]; // PEZ → wHEZ + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString()); + tx = api.tx.utility.batchAll([swapTx, unwrapTx]); + + } else if (fromToken === 'HEZ') { + // HEZ → Any Asset: wrap(HEZ→wHEZ) then swap(wHEZ→Asset) + const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString()); + // Map token symbol to asset ID + const toAssetId = toToken === 'PEZ' ? 1 : toToken === 'USDT' ? 1000 : ASSET_IDS[toToken as keyof typeof ASSET_IDS]; + const swapPath = [0, toAssetId]; // wHEZ → target asset + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + tx = api.tx.utility.batchAll([wrapTx, swapTx]); + + } else if (toToken === 'HEZ') { + // Any Asset → HEZ: swap(Asset→wHEZ) then unwrap(wHEZ→HEZ) + // Map token symbol to asset ID + const fromAssetId = fromToken === 'PEZ' ? 1 : fromToken === 'USDT' ? 1000 : ASSET_IDS[fromToken as keyof typeof ASSET_IDS]; + const swapPath = [fromAssetId, 0]; // source asset → wHEZ + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString()); + tx = api.tx.utility.batchAll([swapTx, unwrapTx]); + + } else { + // Direct swap between assets (PEZ ↔ USDT, etc.) + // Map token symbols to asset IDs + const fromAssetId = fromToken === 'PEZ' ? 1 : fromToken === 'USDT' ? 1000 : ASSET_IDS[fromToken as keyof typeof ASSET_IDS]; + const toAssetId = toToken === 'PEZ' ? 1 : toToken === 'USDT' ? 1000 : ASSET_IDS[toToken as keyof typeof ASSET_IDS]; + const swapPath = [fromAssetId, toAssetId]; + + tx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + } + + // Sign and send transaction + await tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + async ({ status, events, dispatchError }) => { + if (process.env.NODE_ENV !== 'production') console.log('🔍 Transaction status:', status.toHuman()); + + if (status.isInBlock) { + if (process.env.NODE_ENV !== 'production') console.log('✅ Transaction in block:', status.asInBlock.toHex()); + + toast({ + title: 'Transaction Submitted', + description: `Processing in block ${status.asInBlock.toHex().slice(0, 10)}...`, + }); + } + + if (status.isFinalized) { + if (process.env.NODE_ENV !== 'production') console.log('✅ Transaction finalized:', status.asFinalized.toHex()); + if (process.env.NODE_ENV !== 'production') console.log('🔍 All events:', events.map(({ event }) => event.toHuman())); + if (process.env.NODE_ENV !== 'production') console.log('🔍 dispatchError:', dispatchError?.toHuman()); + + // Check for errors + if (dispatchError) { + let errorMessage = 'Transaction failed'; + + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`; + } + + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + setIsSwapping(false); + return; + } + + // Success - check for swap event + const hasSwapEvent = events.some(({ event }) => + api.events.assetConversion?.SwapExecuted?.is(event) + ); + + if (hasSwapEvent || fromToken === 'HEZ' || toToken === 'HEZ') { + toast({ + title: 'Success!', + description: `Swapped ${fromAmount} ${fromToken} for ~${toAmount} ${toToken}`, + }); + + setFromAmount(''); + + // Refresh balances and history without page reload + await refreshBalances(); + if (process.env.NODE_ENV !== 'production') console.log('✅ Balances refreshed after swap'); + + // Refresh swap history after 3 seconds (wait for block finalization) + setTimeout(async () => { + if (process.env.NODE_ENV !== 'production') console.log('🔄 Refreshing swap history...'); + const fetchSwapHistory = async () => { + if (!api || !isApiReady || !isDexAvailable || !selectedAccount) return; + setIsLoadingHistory(true); + try { + const finalizedHead = await api.rpc.chain.getFinalizedHead(); + const finalizedBlock = await api.rpc.chain.getBlock(finalizedHead); + const currentBlockNumber = finalizedBlock.block.header.number.toNumber(); + const startBlock = Math.max(0, currentBlockNumber - 100); + const transactions: SwapTransaction[] = []; + for (let blockNum = currentBlockNumber; blockNum >= startBlock && transactions.length < 10; blockNum--) { + try { + const blockHash = await api.rpc.chain.getBlockHash(blockNum); + const apiAt = await api.at(blockHash); + const events = await apiAt.query.system.events(); + const timestamp = Date.now() - ((currentBlockNumber - blockNum) * 6000); + events.forEach((record: { event: { data: unknown[] } }) => { + const { event } = record; + if (api.events.assetConversion?.SwapExecuted?.is(event)) { + // SwapExecuted has 5 fields: (who, send_to, amountIn, amountOut, path) + const [who, , amountIn, amountOut, path] = event.data; + + // Parse path (same logic as main history fetch) + let fromAssetId = 0; + let toAssetId = 0; + try { + // Path structure is: [[assetId, amount], [assetId, amount]] + const pathArray = path.toJSON ? path.toJSON() : path; + + if (Array.isArray(pathArray) && pathArray.length >= 2) { + const asset0 = pathArray[0]; + const asset1 = pathArray[1]; + + // Each element is a tuple where index 0 is the asset ID + if (Array.isArray(asset0) && asset0.length >= 1) { + fromAssetId = typeof asset0[0] === 'number' ? asset0[0] : parseInt(asset0[0]) || 0; + } + if (Array.isArray(asset1) && asset1.length >= 1) { + toAssetId = typeof asset1[0] === 'number' ? asset1[0] : parseInt(asset1[0]) || 0; + } + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn('Failed to parse swap path in refresh:', err); + } + + const fromTokenSymbol = fromAssetId === 0 ? 'wHEZ' : fromAssetId === 1 ? 'PEZ' : fromAssetId === 1000 ? 'USDT' : `Asset${fromAssetId}`; + const toTokenSymbol = toAssetId === 0 ? 'wHEZ' : toAssetId === 1 ? 'PEZ' : toAssetId === 1000 ? 'USDT' : `Asset${toAssetId}`; + + if (who.toString() === selectedAccount.address) { + transactions.push({ + blockNumber: blockNum, + timestamp, + from: who.toString(), + fromToken: fromTokenSymbol === 'wHEZ' ? 'HEZ' : fromTokenSymbol, + fromAmount: formatBalance(amountIn.toString()), + toToken: toTokenSymbol === 'wHEZ' ? 'HEZ' : toTokenSymbol, + toAmount: formatBalance(amountOut.toString()), + txHash: blockHash.toHex() + }); + } + } + }); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.warn(`Failed to fetch block ${blockNum}:`, err); + } + } + setSwapHistory(transactions.slice(0, 10)); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Failed to refresh swap history:', error); + } finally { + setIsLoadingHistory(false); + } + }; + await fetchSwapHistory(); + }, 3000); + } else { + toast({ + title: 'Error', + description: 'Swap transaction failed', + variant: 'destructive', + }); + } + + setIsSwapping(false); + } + } + ); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Swap failed:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Swap transaction failed', + variant: 'destructive', + }); + setIsSwapping(false); + } + }; + + // Show DEX unavailable message + if (!isDexAvailable && isApiReady) { + return ( +
+ +
+
+
+ +
+
+ +
+

DEX Coming Soon

+

+ The AssetConversion pallet is not yet enabled in the runtime. + Token swapping functionality will be available after the next runtime upgrade. +

+
+ + + Scheduled for Next Runtime Upgrade + + +
+ +
+
+
+
+ ); + } + + return ( +
+ {/* Kurdistan Sun Animation Overlay during swap (only after confirm dialog is closed) */} + {isSwapping && !showConfirm && ( +
+
+ +

+ Processing your swap... +

+
+
+ )} + +
+ {/* Price Chart */} + {exchangeRate > 0 && ( + + )} + + +
+

Token Swap

+ +
+ + {!selectedAccount && ( + + + + Please connect your wallet to swap tokens + + + )} + +
+
+
+ From + + Balance: {fromBalance} {getTokenDisplayName(fromToken)} + +
+
+ setFromAmount(e.target.value)} + placeholder="Amount" + className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-500 placeholder:opacity-50" + disabled={!selectedAccount} + /> + +
+
+ +
+ +
+ +
+
+ To + + Balance: {toBalance} {getTokenDisplayName(toToken)} + +
+
+ + +
+
+ + {/* Swap Details - Uniswap Style */} +
+
+ + + Exchange Rate + + + {isLoadingRate ? ( + 'Loading...' + ) : exchangeRate > 0 ? ( + `1 ${getTokenDisplayName(fromToken)} = ${exchangeRate.toFixed(4)} ${getTokenDisplayName(toToken)}` + ) : ( + 'No pool available' + )} + +
+ + {/* Price Impact Indicator (Uniswap style) */} + {fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && ( +
+ + + Price Impact + + + {priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`} + +
+ )} + + {/* LP Fee */} + {fromAmount && parseFloat(fromAmount) > 0 && lpFee && ( +
+ Liquidity Provider Fee + {lpFee} {getTokenDisplayName(fromToken)} +
+ )} + + {/* Minimum Received */} + {fromAmount && parseFloat(fromAmount) > 0 && minimumReceived && ( +
+ Minimum Received + {minimumReceived} {getTokenDisplayName(toToken)} +
+ )} + +
+ Slippage Tolerance + {slippage}% +
+
+ + {/* Insufficient Balance Warning */} + {hasInsufficientBalance && ( + + + + Insufficient {getTokenDisplayName(fromToken)} balance. You have {fromBalance} {getTokenDisplayName(fromToken)} but trying to swap {fromAmount} {getTokenDisplayName(fromToken)}. + + + )} + + {/* High Price Impact Warning (>5%) */} + {priceImpact >= 5 && !hasInsufficientBalance && ( + + + + High price impact! Your trade will significantly affect the pool price. Consider a smaller amount or check if there's better liquidity. + + + )} + + +
+
+ + +

+ + Liquidity Pools +

+ + {isLoadingPools ? ( +
Loading pools...
+ ) : liquidityPools.length > 0 ? ( +
+ {liquidityPools.map((pool, idx) => ( +
+
+
{pool.pool}
+
TVL: {pool.tvl}
+
+
+
{pool.apr} APR
+
Vol: {pool.volume}
+
+
+ ))} +
+ ) : ( +
+ No liquidity pools available yet +
+ )} +
+
+ +
+ +

+ + Recent Swaps +

+ + {!selectedAccount ? ( +
+ Connect wallet to view history +
+ ) : isLoadingHistory ? ( +
+ Loading history... +
+ ) : swapHistory.length > 0 ? ( +
+ {swapHistory.map((tx, idx) => ( +
+
+
+ + + {getTokenDisplayName(tx.fromToken)} → {getTokenDisplayName(tx.toToken)} + +
+ + #{tx.blockNumber} + +
+
+
+ Sent: + -{tx.fromAmount} {getTokenDisplayName(tx.fromToken)} +
+
+ Received: + +{tx.toAmount} {getTokenDisplayName(tx.toToken)} +
+
+ {new Date(tx.timestamp).toLocaleDateString()} + {new Date(tx.timestamp).toLocaleTimeString()} +
+
+
+ ))} +
+ ) : ( +
+ No swap history yet +
+ )} +
+
+ + + + + Swap Settings + +
+
+ +
+ {['0.1', '0.5', '1.0'].map(val => ( + + ))} + setSlippage(e.target.value)} + className="w-20" + /> +
+
+
+
+
+ + + + + Confirm Swap + +
+
+
+ You Pay + {fromAmount} {getTokenDisplayName(fromToken)} +
+
+ You Receive + {toAmount} {getTokenDisplayName(toToken)} +
+
+ Exchange Rate + 1 {getTokenDisplayName(fromToken)} = {exchangeRate.toFixed(4)} {getTokenDisplayName(toToken)} +
+
+ Slippage + {slippage}% +
+
+ +
+
+
+
+ ); +}; + +export default TokenSwap; \ No newline at end of file diff --git a/packages/apps/src/components/TokenomicsSection.tsx b/packages/apps/src/components/TokenomicsSection.tsx new file mode 100644 index 0000000..39522c6 --- /dev/null +++ b/packages/apps/src/components/TokenomicsSection.tsx @@ -0,0 +1,179 @@ +import React, { useState, useEffect } from 'react'; +import { PieChart, ArrowRightLeft } from 'lucide-react'; + +const TokenomicsSection: React.FC = () => { + const [selectedToken, setSelectedToken] = useState<'PEZ' | 'HEZ'>('PEZ'); + const [monthsPassed] = useState(0); + + const halvingPeriod = Math.floor(monthsPassed / 48); + //const _monthsUntilNextHalving = 48 - (monthsPassed % 48); + + useEffect(() => { + const baseAmount = selectedToken === 'PEZ' ? 74218750 : 37109375; + // Calculate release amount for future use + const releaseAmount = baseAmount / Math.pow(2, halvingPeriod); + if (process.env.NODE_ENV !== 'production') console.log('Release amount:', releaseAmount); + }, [monthsPassed, halvingPeriod, selectedToken]); + + const pezDistribution = [ + { name: 'Treasury', percentage: 96.25, amount: 4812500000, color: 'from-purple-500 to-purple-600' }, + { name: 'Presale', percentage: 1.875, amount: 93750000, color: 'from-cyan-500 to-cyan-600' }, + { name: 'Founder', percentage: 1.875, amount: 93750000, color: 'from-teal-500 to-teal-600' } + ]; + + const hezDistribution = [ + { name: 'Staking Rewards', percentage: 40, amount: 1000000000, color: 'from-yellow-500 to-orange-600' }, + { name: 'Governance', percentage: 30, amount: 750000000, color: 'from-green-500 to-emerald-600' }, + { name: 'Ecosystem', percentage: 20, amount: 500000000, color: 'from-blue-500 to-indigo-600' }, + { name: 'Team', percentage: 10, amount: 250000000, color: 'from-red-500 to-pink-600' } + ]; + + const distribution = selectedToken === 'PEZ' ? pezDistribution : hezDistribution; + const totalSupply = selectedToken === 'PEZ' ? 5000000000 : 2500000000; + const tokenColor = selectedToken === 'PEZ' ? 'purple' : 'yellow'; + + return ( +
+
+
+

+ Dual Token Ecosystem +

+

+ PEZ & HEZ tokens working together for governance and utility +

+ + {/* Token Selector */} +
+ + +
+
+ +
+ {/* Distribution Chart */} +
+
+ +

{selectedToken} Distribution

+
+ +
+
+ {selectedToken} +
+
+ +
+ {distribution.map((item) => ( +
+
+
+ {item.name} +
+
+
{item.percentage}%
+
{item.amount.toLocaleString()} {selectedToken}
+
+
+ ))} +
+ +
+
+ Total Supply + {totalSupply.toLocaleString()} {selectedToken} +
+
+
+ + {/* Token Features */} +
+
+ +

{selectedToken} Features

+
+ + {selectedToken === 'PEZ' ? ( +
+
+

Governance Token

+

Vote on proposals and participate in DAO decisions

+
+
+

Staking Rewards

+

Earn HEZ tokens by staking PEZ

+
+
+

Treasury Access

+

Propose and vote on treasury fund allocation

+
+
+

Deflationary

+

Synthetic halving every 48 months

+
+
+ ) : ( +
+
+

Utility Token

+

Used for platform transactions and services

+
+
+

P2P Trading

+

Primary currency for peer-to-peer marketplace

+
+
+

Fee Discounts

+

Reduced platform fees when using HEZ

+
+
+

Reward Distribution

+

Earned through staking and participation

+
+
+ )} + +
+

Token Synergy

+
+
+ + Stake PEZ → Earn HEZ rewards +
+
+ + Use HEZ → Boost governance power +
+
+ + Hold both → Maximum platform benefits +
+
+
+
+
+
+
+ ); +}; + +export default TokenomicsSection; \ No newline at end of file diff --git a/packages/apps/src/components/TransactionHistory.tsx b/packages/apps/src/components/TransactionHistory.tsx new file mode 100644 index 0000000..256d611 --- /dev/null +++ b/packages/apps/src/components/TransactionHistory.tsx @@ -0,0 +1,385 @@ +import React, { useEffect, useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { History, ExternalLink, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +interface TransactionHistoryProps { + isOpen: boolean; + onClose: () => void; +} + +interface Transaction { + blockNumber: number; + extrinsicIndex: number; + hash: string; + method: string; + section: string; + from: string; + to?: string; + amount?: string; + success: boolean; + timestamp?: number; +} + +export const TransactionHistory: React.FC = ({ isOpen, onClose }) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { toast } = useToast(); + const [transactions, setTransactions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchTransactions = async () => { + if (!api || !isApiReady || !selectedAccount) return; + + setIsLoading(true); + try { + if (process.env.NODE_ENV !== 'production') console.log('Fetching transactions...'); + const currentBlock = await api.rpc.chain.getBlock(); + const currentBlockNumber = currentBlock.block.header.number.toNumber(); + + if (process.env.NODE_ENV !== 'production') console.log('Current block number:', currentBlockNumber); + + const txList: Transaction[] = []; + const blocksToCheck = Math.min(200, currentBlockNumber); + + for (let i = 0; i < blocksToCheck && txList.length < 20; i++) { + const blockNumber = currentBlockNumber - i; + + try { + const blockHash = await api.rpc.chain.getBlockHash(blockNumber); + const block = await api.rpc.chain.getBlock(blockHash); + + // Try to get timestamp, but don't fail if state is pruned + let timestamp = 0; + try { + const ts = await api.query.timestamp.now.at(blockHash); + timestamp = ts.toNumber(); + } catch { + // State pruned, use current time as fallback + timestamp = Date.now(); + } + + if (process.env.NODE_ENV !== 'production') console.log(`Block #${blockNumber}: ${block.block.extrinsics.length} extrinsics`); + + // Check each extrinsic in the block + block.block.extrinsics.forEach((extrinsic, index) => { + // Skip unsigned extrinsics (system calls) + if (!extrinsic.isSigned) { + return; + } + + const { method, signer } = extrinsic; + + if (process.env.NODE_ENV !== 'production') console.log(` Extrinsic #${index}: ${method.section}.${method.method}, signer: ${signer.toString()}`); + + // Check if transaction involves our account + const fromAddress = signer.toString(); + const isFromOurAccount = fromAddress === selectedAccount.address; + + // Only track transactions from this account + if (!isFromOurAccount) { + return; + } + + // Parse balances.transfer or balances.transferKeepAlive + if (method.section === 'balances' && + (method.method === 'transfer' || method.method === 'transferKeepAlive')) { + const [dest, value] = method.args; + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + to: dest.toString(), + amount: value.toString(), + success: true, + timestamp: timestamp, + }); + } + + // Parse assets.transfer (PEZ, USDT, etc.) + else if (method.section === 'assets' && method.method === 'transfer') { + const [assetId, dest, value] = method.args; + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: `${method.method} (Asset ${assetId.toString()})`, + section: method.section, + from: fromAddress, + to: dest.toString(), + amount: value.toString(), + success: true, + timestamp: timestamp, + }); + } + + // Parse staking operations + else if (method.section === 'staking') { + if (method.method === 'bond' || method.method === 'bondExtra') { + const value = method.args[method.method === 'bond' ? 1 : 0]; + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + amount: value.toString(), + success: true, + timestamp: timestamp, + }); + } else if (method.method === 'unbond') { + const [value] = method.args; + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + amount: value.toString(), + success: true, + timestamp: timestamp, + }); + } else if (method.method === 'nominate' || method.method === 'withdrawUnbonded' || method.method === 'chill') { + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + success: true, + timestamp: timestamp, + }); + } + } + + // Parse DEX operations + else if (method.section === 'dex') { + if (method.method === 'swap') { + const [, amountIn] = method.args; + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + amount: amountIn.toString(), + success: true, + timestamp: timestamp, + }); + } else if (method.method === 'addLiquidity' || method.method === 'removeLiquidity') { + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + success: true, + timestamp: timestamp, + }); + } + } + + // Parse stakingScore operations + else if (method.section === 'stakingScore' && method.method === 'startTracking') { + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + success: true, + timestamp: timestamp, + }); + } + + // Parse pezRewards operations + else if (method.section === 'pezRewards' && method.method === 'claimReward') { + txList.push({ + blockNumber, + extrinsicIndex: index, + hash: extrinsic.hash.toHex(), + method: method.method, + section: method.section, + from: fromAddress, + success: true, + timestamp: timestamp, + }); + } + }); + } catch (blockError) { + if (process.env.NODE_ENV !== 'production') console.warn(`Error processing block #${blockNumber}:`, blockError); + // Continue to next block + } + } + + if (process.env.NODE_ENV !== 'production') console.log('Found transactions:', txList.length); + + setTransactions(txList); + } catch { + if (process.env.NODE_ENV !== 'production') console.error('Failed to fetch transactions:', error); + toast({ + title: "Error", + description: "Failed to fetch transaction history", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + fetchTransactions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, api, isApiReady, selectedAccount]); + + + const formatAmount = (amount: string, decimals: number = 12) => { + const value = parseInt(amount) / Math.pow(10, decimals); + return value.toFixed(4); + }; + + const formatTimestamp = (timestamp?: number) => { + if (!timestamp) return 'Unknown'; + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + const isIncoming = (tx: Transaction) => { + return tx.to === selectedAccount?.address; + }; + + return ( + + + +
+
+ Transaction History + + Recent transactions involving your account + +
+ +
+
+ +
+ {isLoading ? ( +
+ +

Loading transactions...

+
+ ) : transactions.length === 0 ? ( +
+ +

No transactions found

+

+ Your recent transactions will appear here +

+
+ ) : ( + transactions.map((tx) => ( +
+
+
+ {isIncoming(tx) ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ {isIncoming(tx) ? 'Received' : 'Sent'} +
+
+ {tx.section}.{tx.method} +
+
+
+
+
+ {isIncoming(tx) ? '+' : '-'}{formatAmount(tx.amount || '0')} +
+
+ Block #{tx.blockNumber} +
+
+
+ +
+
+ From: +
+ {tx.from.slice(0, 8)}...{tx.from.slice(-6)} +
+
+ {tx.to && ( +
+ To: +
+ {tx.to.slice(0, 8)}...{tx.to.slice(-6)} +
+
+ )} +
+ +
+
+ {formatTimestamp(tx.timestamp)} +
+ +
+
+ )) + )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/apps/src/components/TransferModal.tsx b/packages/apps/src/components/TransferModal.tsx new file mode 100644 index 0000000..71fa3e6 --- /dev/null +++ b/packages/apps/src/components/TransferModal.tsx @@ -0,0 +1,349 @@ +import React, { useState } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +interface TokenBalance { + assetId: number; + symbol: string; + name: string; + balance: string; + decimals: number; + usdValue: number; +} + +interface TransferModalProps { + isOpen: boolean; + onClose: () => void; + selectedAsset?: TokenBalance | null; +} + +type TokenType = 'HEZ' | 'PEZ' | 'USDT' | 'BTC' | 'ETH' | 'DOT'; + +interface Token { + symbol: TokenType; + name: string; + assetId?: number; + decimals: number; + color: string; +} + +// Token logo mapping +const TOKEN_LOGOS: Record = { + HEZ: '/tokens/HEZ.png', + PEZ: '/tokens/PEZ.png', + USDT: '/tokens/USDT.png', + BTC: '/tokens/BTC.png', + ETH: '/tokens/ETH.png', + DOT: '/tokens/DOT.png', + BNB: '/tokens/BNB.png', +}; + +const TOKENS: Token[] = [ + { symbol: 'HEZ', name: 'Hez Token', decimals: 12, color: 'from-green-600 to-yellow-400' }, + { symbol: 'PEZ', name: 'Pez Token', assetId: 1, decimals: 12, color: 'from-blue-600 to-purple-400' }, + { symbol: 'USDT', name: 'Tether USD', assetId: 1000, decimals: 6, color: 'from-green-500 to-green-600' }, + { symbol: 'BTC', name: 'Bitcoin', assetId: 3, decimals: 8, color: 'from-orange-500 to-yellow-500' }, + { symbol: 'ETH', name: 'Ethereum', assetId: 4, decimals: 18, color: 'from-purple-500 to-blue-500' }, + { symbol: 'DOT', name: 'Polkadot', assetId: 5, decimals: 10, color: 'from-pink-500 to-red-500' }, +]; + +export const TransferModal: React.FC = ({ isOpen, onClose, selectedAsset }) => { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { toast } = useToast(); + + const [selectedToken, setSelectedToken] = useState('HEZ'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [isTransferring, setIsTransferring] = useState(false); + const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle'); + const [txHash, setTxHash] = useState(''); + + // Use the provided selectedAsset or fall back to token selection + const currentToken = selectedAsset ? { + symbol: selectedAsset.symbol as TokenType, + name: selectedAsset.name, + assetId: selectedAsset.assetId, + decimals: selectedAsset.decimals, + color: selectedAsset.assetId === 0 ? 'from-green-600 to-yellow-400' : + selectedAsset.assetId === 1000 ? 'from-emerald-500 to-teal-500' : + 'from-cyan-500 to-blue-500', + } : TOKENS.find(t => t.symbol === selectedToken) || TOKENS[0]; + + const handleTransfer = async () => { + if (!api || !isApiReady || !selectedAccount) { + toast({ + title: "Error", + description: "Wallet not connected", + variant: "destructive", + }); + return; + } + + if (!recipient || !amount) { + toast({ + title: "Error", + description: "Please fill in all fields", + variant: "destructive", + }); + return; + } + + setIsTransferring(true); + setTxStatus('signing'); + + try { + // Import web3FromAddress to get the injector + const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + // Convert amount to smallest unit + const amountInSmallestUnit = BigInt(parseFloat(amount) * Math.pow(10, currentToken.decimals)); + + let transfer; + + // Create appropriate transfer transaction based on token type + // wHEZ uses native token transfer (balances pallet), all others use assets pallet + if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) { + // Native HEZ token transfer + transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString()); + } else { + // Asset token transfer (wHEZ, PEZ, wUSDT, etc.) + transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString()); + } + + setTxStatus('pending'); + + // Sign and send transaction + const unsub = await transfer.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isInBlock) { + if (process.env.NODE_ENV !== 'production') console.log(`Transaction included in block: ${status.asInBlock}`); + setTxHash(status.asInBlock.toHex()); + } + + if (status.isFinalized) { + if (process.env.NODE_ENV !== 'production') console.log(`Transaction finalized: ${status.asFinalized}`); + + // Check for errors + if (dispatchError) { + let errorMessage = 'Transaction failed'; + + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`; + } + + setTxStatus('error'); + toast({ + title: "Transfer Failed", + description: errorMessage, + variant: "destructive", + }); + } else { + setTxStatus('success'); + toast({ + title: "Transfer Successful!", + description: `Sent ${amount} ${currentToken.symbol} to ${recipient.slice(0, 8)}...${recipient.slice(-6)}`, + }); + + // Reset form after 2 seconds + setTimeout(() => { + setRecipient(''); + setAmount(''); + setTxStatus('idle'); + setTxHash(''); + onClose(); + }, 2000); + } + + setIsTransferring(false); + unsub(); + } + } + ); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Transfer error:', error); + setTxStatus('error'); + setIsTransferring(false); + + toast({ + title: "Transfer Failed", + description: error instanceof Error ? error.message : "An error occurred during transfer", + variant: "destructive", + }); + } + }; + + const handleClose = () => { + if (!isTransferring) { + setRecipient(''); + setAmount(''); + setTxStatus('idle'); + setTxHash(''); + setSelectedToken('HEZ'); + onClose(); + } + }; + + return ( + + + + + {selectedAsset ? `Send ${selectedAsset.symbol}` : 'Send Tokens'} + + + {selectedAsset + ? `Transfer ${selectedAsset.name} to another account` + : 'Transfer tokens to another account'} + + + + {txStatus === 'success' ? ( +
+ +

Transfer Successful!

+

Your transaction has been finalized

+ {txHash && ( +
+
Transaction Hash
+
+ {txHash} +
+
+ )} +
+ ) : txStatus === 'error' ? ( +
+ +

Transfer Failed

+

Please try again

+ +
+ ) : ( +
+ {/* Token Selection - Only show if no asset is pre-selected */} + {!selectedAsset && ( +
+ + +
+ )} + +
+ + setRecipient(e.target.value)} + placeholder="Recipient address" + className="bg-gray-800 border-gray-700 text-white mt-2 placeholder:text-gray-500 placeholder:opacity-50" + disabled={isTransferring} + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="Amount" + className="bg-gray-800 border-gray-700 text-white mt-2 placeholder:text-gray-500 placeholder:opacity-50" + disabled={isTransferring} + /> +
+ Decimals: {currentToken.decimals} +
+
+ + {txStatus === 'signing' && ( +
+

+ Please sign the transaction in your Pezkuwi.js extension +

+
+ )} + + {txStatus === 'pending' && ( +
+

+ + Transaction pending... Waiting for finalization +

+
+ )} + + +
+ )} +
+
+ ); +}; diff --git a/packages/apps/src/components/TrustScoreCalculator.tsx b/packages/apps/src/components/TrustScoreCalculator.tsx new file mode 100644 index 0000000..414c2c1 --- /dev/null +++ b/packages/apps/src/components/TrustScoreCalculator.tsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect } from 'react'; +import { Calculator, TrendingUp, Users, BookOpen, Award } from 'lucide-react'; + +const TrustScoreCalculator: React.FC = () => { + const [stakedAmount, setStakedAmount] = useState(100); + const [stakingMonths, setStakingMonths] = useState(6); + const [referralCount, setReferralCount] = useState(5); + const [perwerdeScore, setPerwerdeScore] = useState(30); + const [tikiScore, setTikiScore] = useState(40); + const [finalScore, setFinalScore] = useState(0); + + // Calculate base amount score based on pallet_staking_score logic + const getAmountScore = (amount: number) => { + if (amount <= 100) return 20; + if (amount <= 250) return 30; + if (amount <= 750) return 40; + return 50; // 751+ HEZ + }; + + // Calculate staking multiplier based on months + const getStakingMultiplier = (months: number) => { + if (months < 1) return 1.0; + if (months < 3) return 1.2; + if (months < 6) return 1.4; + if (months < 12) return 1.7; + return 2.0; + }; + + // Calculate referral score + const getReferralScore = (count: number) => { + if (count === 0) return 0; + if (count <= 5) return count * 4; + if (count <= 20) return 20 + ((count - 5) * 2); + return 50; + }; + + useEffect(() => { + const amountScore = getAmountScore(stakedAmount); + const multiplier = getStakingMultiplier(stakingMonths); + const adjustedStaking = Math.min(amountScore * multiplier, 100); + const adjustedReferral = getReferralScore(referralCount); + + const weightedSum = + adjustedStaking * 100 + + adjustedReferral * 300 + + perwerdeScore * 300 + + tikiScore * 300; + + const score = (adjustedStaking * weightedSum) / 1000; + setFinalScore(Math.round(score)); + }, [stakedAmount, stakingMonths, referralCount, perwerdeScore, tikiScore]); + + return ( +
+
+
+

+ Trust Score Calculator +

+

+ Simulate your trust score based on staking, referrals, education, and roles +

+
+ +
+ {/* Calculator Inputs */} +
+ {/* Staking Score */} +
+
+ +

Staking Amount

+
+ +
+
+ + setStakedAmount(parseInt(e.target.value))} + className="w-full mt-2" + /> +
+ {stakedAmount} HEZ + Score: {getAmountScore(stakedAmount)} +
+
+ +
+ + setStakingMonths(parseInt(e.target.value))} + className="w-full mt-2" + /> +
+ {stakingMonths} months + ×{getStakingMultiplier(stakingMonths).toFixed(1)} multiplier +
+
+
+
+ + {/* Referral Score */} +
+
+ +

Referral Score

+
+ +
+ + setReferralCount(parseInt(e.target.value) || 0)} + className="w-full mt-2 px-4 py-2 bg-gray-800 text-white rounded-lg border border-gray-700 focus:border-cyan-500 focus:outline-none" + /> +
+ Score: {getReferralScore(referralCount)} points +
+
+
+ + {/* Other Scores */} +
+
+
+ +

Perwerde Score

+
+ setPerwerdeScore(parseInt(e.target.value))} + className="w-full" + /> +
{perwerdeScore}
+
+ +
+
+ +

Tiki Score

+
+ setTikiScore(parseInt(e.target.value))} + className="w-full" + /> +
{tikiScore}
+
+
+
+ + {/* Results and Formula */} +
+ {/* Final Score */} +
+ +

Final Trust Score

+
+ {finalScore} +
+
+ Out of theoretical maximum +
+
+ + {/* Formula Breakdown */} +
+

Formula Breakdown

+ +
+
+ weighted_sum = +
+
+ staking × 100 + +
+
+ referral × 300 + +
+
+ perwerde × 300 + +
+
+ tiki × 300 +
+
+ final_score = staking × weighted_sum / 1000 +
+
+ +
+
+ Staking Component: + {Math.min(Math.round(getAmountScore(stakedAmount) * getStakingMultiplier(stakingMonths)), 100)} × 100 +
+
+ Referral Component: + {getReferralScore(referralCount)} × 300 +
+
+ Perwerde Component: + {perwerdeScore} × 300 +
+
+ Tiki Component: + {tikiScore} × 300 +
+
+
+ + {/* Score Impact */} +
+

Score Impact

+
+
+ Monthly Rewards Eligibility + 100 ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'}`}> + {finalScore > 100 ? 'Eligible' : 'Not Eligible'} + +
+
+ Governance Voting Weight + {Math.min(Math.floor(finalScore / 100), 10)}x +
+
+
+
+
+
+
+ ); +}; + +export default TrustScoreCalculator; \ No newline at end of file diff --git a/packages/apps/src/components/USDTBridge.tsx b/packages/apps/src/components/USDTBridge.tsx new file mode 100644 index 0000000..cb70e99 --- /dev/null +++ b/packages/apps/src/components/USDTBridge.tsx @@ -0,0 +1,354 @@ +import React, { useState, useEffect } from 'react'; +import { X, ArrowDown, ArrowUp, AlertCircle, Info, Clock, CheckCircle2 } from 'lucide-react'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { + getWUSDTBalance, + calculateWithdrawalDelay, + getWithdrawalTier, + formatDelay, + formatWUSDT, +} from '@pezkuwi/lib/usdt'; +import { isMultisigMember } from '@pezkuwi/lib/multisig'; +import { ASSET_IDS } from '@pezkuwi/lib/wallet'; + +interface USDTBridgeProps { + isOpen: boolean; + onClose: () => void; + specificAddresses?: Record; +} + +export const USDTBridge: React.FC = ({ + isOpen, + onClose, + specificAddresses = {}, +}) => { + const { api, selectedAccount, isApiReady } = usePezkuwi(); + const { refreshBalances } = useWallet(); + + const [depositAmount, setDepositAmount] = useState(''); + const [withdrawAmount, setWithdrawAmount] = useState(''); + const [withdrawAddress, setWithdrawAddress] = useState(''); // Bank account or crypto address + const [wusdtBalance, setWusdtBalance] = useState(0); + const [isMultisigMemberState, setIsMultisigMemberState] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + + // Fetch wUSDT balance + useEffect(() => { + if (!api || !isApiReady || !selectedAccount || !isOpen) return; + + const fetchBalance = async () => { + const balance = await getWUSDTBalance(api, selectedAccount.address); + setWusdtBalance(balance); + + // Check if user is multisig member + const isMember = await isMultisigMember(api, selectedAccount.address, specificAddresses); + setIsMultisigMemberState(isMember); + }; + + fetchBalance(); + }, [api, isApiReady, selectedAccount, isOpen, specificAddresses]); + + // Handle deposit (user requests deposit) + const handleDeposit = async () => { + if (!depositAmount || parseFloat(depositAmount) <= 0) { + setError('Please enter a valid amount'); + return; + } + + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + // In real implementation: + // 1. User transfers USDT to treasury (off-chain) + // 2. Notary verifies the transfer + // 3. Multisig mints wUSDT to user + + // For now, just show instructions + setSuccess( + `Deposit request for ${depositAmount} USDT created. Please follow the instructions to complete the deposit.` + ); + setDepositAmount(''); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Deposit error:', err); + setError(err instanceof Error ? err.message : 'Deposit failed'); + } finally { + setIsLoading(false); + } + }; + + // Handle withdrawal (burn wUSDT) + const handleWithdrawal = async () => { + if (!api || !selectedAccount) return; + + const amount = parseFloat(withdrawAmount); + + if (!amount || amount <= 0) { + setError('Please enter a valid amount'); + return; + } + + if (amount > wusdtBalance) { + setError('Insufficient wUSDT balance'); + return; + } + + if (!withdrawAddress) { + setError('Please enter withdrawal address'); + return; + } + + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + const injector = await web3FromAddress(selectedAccount.address); + + // Burn wUSDT + const amountBN = BigInt(Math.floor(amount * 1e6)); // 6 decimals + const burnTx = api.tx.assets.burn(ASSET_IDS.WUSDT, selectedAccount.address, amountBN.toString()); + + await burnTx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status }) => { + if (status.isFinalized) { + const delay = calculateWithdrawalDelay(amount); + setSuccess( + `Withdrawal request submitted! wUSDT burned. USDT will be sent to ${withdrawAddress} after ${formatDelay(delay)}.` + ); + setWithdrawAmount(''); + setWithdrawAddress(''); + refreshBalances(); + setIsLoading(false); + } + }); + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Withdrawal error:', err); + setError(err instanceof Error ? err.message : 'Withdrawal failed'); + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + const withdrawalTier = withdrawAmount ? getWithdrawalTier(parseFloat(withdrawAmount)) : null; + const withdrawalDelay = withdrawAmount ? calculateWithdrawalDelay(parseFloat(withdrawAmount)) : 0; + + return ( +
+
+ {/* Header */} +
+
+

USDT Bridge

+

Deposit or withdraw USDT

+
+ +
+ + {/* Balance Display */} +
+

Your wUSDT Balance

+

{formatWUSDT(wusdtBalance)}

+ {isMultisigMemberState && ( + + Multisig Member + + )} +
+ + {/* Error/Success Alerts */} + {error && ( + + + {error} + + )} + + {success && ( + + + {success} + + )} + + {/* Tabs */} + + + Deposit + Withdraw + + + {/* Deposit Tab */} + + + + +

How to Deposit:

+
    +
  1. Transfer USDT to the treasury account (off-chain)
  2. +
  3. Notary verifies and records your transaction
  4. +
  5. Multisig (3/5) approves and mints wUSDT to your account
  6. +
  7. Receive wUSDT in 2-5 minutes
  8. +
+
+
+ +
+ + setDepositAmount(e.target.value)} + placeholder="Amount" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50" + disabled={isLoading} + /> +
+ +
+
+ You will receive: + + {depositAmount || '0.00'} wUSDT + +
+
+ Exchange rate: + 1:1 +
+
+ Estimated time: + 2-5 minutes +
+
+ + +
+ + {/* Withdraw Tab */} + + + + +

How to Withdraw:

+
    +
  1. Burn your wUSDT on-chain
  2. +
  3. Wait for security delay ({withdrawalDelay > 0 && formatDelay(withdrawalDelay)})
  4. +
  5. Multisig (3/5) approves and sends USDT
  6. +
  7. Receive USDT to your specified address
  8. +
+
+
+ +
+ + setWithdrawAmount(e.target.value)} + placeholder="Amount" + max={wusdtBalance} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50" + disabled={isLoading} + /> + +
+ +
+ + setWithdrawAddress(e.target.value)} + placeholder="Bank account or crypto address" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50" + disabled={isLoading} + /> +
+ + {withdrawAmount && parseFloat(withdrawAmount) > 0 && ( +
+
+ You will receive: + {withdrawAmount} USDT +
+
+ Withdrawal tier: + + {withdrawalTier} + +
+
+ Security delay: + + + {formatDelay(withdrawalDelay)} + +
+
+ )} + + +
+
+
+
+ ); +}; diff --git a/packages/apps/src/components/admin/CommissionSetupTab.tsx b/packages/apps/src/components/admin/CommissionSetupTab.tsx new file mode 100644 index 0000000..b279073 --- /dev/null +++ b/packages/apps/src/components/admin/CommissionSetupTab.tsx @@ -0,0 +1,476 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/hooks/use-toast'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Loader2, Plus, CheckCircle, AlertTriangle, Shield } from 'lucide-react'; +import { COMMISSIONS } from '@/config/commissions'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +export function CommissionSetupTab() { + const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { toast } = useToast(); + + const [loading, setLoading] = useState(true); + const [commissionMembers, setCommissionMembers] = useState([]); + const [setupComplete, setSetupComplete] = useState(false); + const [processing, setProcessing] = useState(false); + const [newMemberAddress, setNewMemberAddress] = useState(''); + + useEffect(() => { + if (!api || !isApiReady) return; + checkSetup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, isApiReady]); + + + const checkSetup = async () => { + if (!api) return; + + setLoading(true); + try { + // Check DynamicCommissionCollective members + const members = await api.query.dynamicCommissionCollective.members(); + const memberList = members.toJSON() as string[]; + + setCommissionMembers(memberList); + // Commission is initialized if there's at least one member + setSetupComplete(memberList.length > 0); + + if (process.env.NODE_ENV !== 'production') console.log('Commission members:', memberList); + if (process.env.NODE_ENV !== 'production') console.log('Setup complete:', memberList.length > 0); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Error checking setup:', error); + } finally { + setLoading(false); + } + }; + + const handleAddMember = async () => { + if (!api || !selectedAccount) { + toast({ + title: 'Wallet Not Connected', + description: 'Please connect your admin wallet', + variant: 'destructive', + }); + return; + } + + if (!newMemberAddress) { + toast({ + title: 'No Addresses', + description: 'Please enter at least one address', + variant: 'destructive', + }); + return; + } + + setProcessing(true); + try { + const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + // Parse addresses (one per line, trim whitespace) + const newAddresses = newMemberAddress + .split('\n') + .map(addr => addr.trim()) + .filter(addr => addr.length > 0); + + if (newAddresses.length === 0) { + toast({ + title: 'No Valid Addresses', + description: 'Please enter at least one valid address', + variant: 'destructive', + }); + setProcessing(false); + return; + } + + // Get current members + const currentMembers = await api.query.dynamicCommissionCollective.members(); + const memberList = (currentMembers.toJSON() as string[]) || []; + + // Filter out already existing members + const newMembers = newAddresses.filter(addr => !memberList.includes(addr)); + + if (newMembers.length === 0) { + toast({ + title: 'Already Members', + description: 'All addresses are already commission members', + variant: 'destructive', + }); + setProcessing(false); + return; + } + + // Add new members + const updatedList = [...memberList, ...newMembers]; + + if (process.env.NODE_ENV !== 'production') console.log('Adding new members:', newMembers); + if (process.env.NODE_ENV !== 'production') console.log('Updated member list:', updatedList); + + const tx = api.tx.sudo.sudo( + api.tx.dynamicCommissionCollective.setMembers( + updatedList, + null, + updatedList.length + ) + ); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isInBlock || status.isFinalized) { + if (dispatchError) { + let errorMessage = 'Failed to add member'; + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}`; + } + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + reject(new Error(errorMessage)); + } else { + toast({ + title: 'Success', + description: `${newMembers.length} member(s) added successfully!`, + }); + setNewMemberAddress(''); + setTimeout(() => checkSetup(), 2000); + resolve(); + } + } + } + ); + }); + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Error adding member:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to add member', + variant: 'destructive', + }); + } finally { + setProcessing(false); + } + }; + + const handleInitializeCommission = async () => { + if (!api || !selectedAccount) { + toast({ + title: 'Wallet Not Connected', + description: 'Please connect your admin wallet', + variant: 'destructive', + }); + return; + } + + setProcessing(true); + try { + const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + if (process.env.NODE_ENV !== 'production') console.log('Initializing KYC Commission...'); + if (process.env.NODE_ENV !== 'production') console.log('Proxy account:', COMMISSIONS.KYC.proxyAccount); + + // Initialize DynamicCommissionCollective with Alice as first member + // Other members can be added later + const tx = api.tx.sudo.sudo( + api.tx.dynamicCommissionCollective.setMembers( + [selectedAccount.address], // Add caller as first member + null, + 1 + ) + ); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError, events }) => { + if (process.env.NODE_ENV !== 'production') console.log('Transaction status:', status.type); + + if (status.isInBlock || status.isFinalized) { + if (dispatchError) { + let errorMessage = 'Transaction failed'; + + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + } else { + errorMessage = dispatchError.toString(); + } + + if (process.env.NODE_ENV !== 'production') console.error('Setup error:', errorMessage); + toast({ + title: 'Setup Failed', + description: errorMessage, + variant: 'destructive', + }); + reject(new Error(errorMessage)); + return; + } + + // Check for Sudid event + const sudidEvent = events.find(({ event }) => + event.section === 'sudo' && event.method === 'Sudid' + ); + + if (sudidEvent) { + if (process.env.NODE_ENV !== 'production') console.log('✅ KYC Commission initialized'); + toast({ + title: 'Success', + description: 'KYC Commission initialized successfully!', + }); + resolve(); + } else { + if (process.env.NODE_ENV !== 'production') console.warn('Transaction included but no Sudid event'); + resolve(); + } + } + } + ).catch((error) => { + if (process.env.NODE_ENV !== 'production') console.error('Failed to sign and send:', error); + toast({ + title: 'Transaction Error', + description: error instanceof Error ? error.message : 'Failed to submit transaction', + variant: 'destructive', + }); + reject(error); + }); + }); + + // Reload setup status + setTimeout(() => checkSetup(), 2000); + + } catch (error) { + if (process.env.NODE_ENV !== 'production') console.error('Error initializing commission:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to initialize commission', + variant: 'destructive', + }); + } finally { + setProcessing(false); + } + }; + + if (!isApiReady) { + return ( + + +
+ + Connecting to blockchain... +
+
+
+ ); + } + + if (!selectedAccount) { + return ( + + + + + + Please connect your admin wallet to manage commission setup. + + + + + ); + } + + return ( +
+ {/* Setup Status */} + + + + + KYC Commission Setup + + + + {loading ? ( +
+ +
+ ) : ( + <> +
+
+

Commission Status

+

+ {setupComplete + ? 'Commission is initialized and ready' + : 'Commission needs to be initialized'} +

+
+ {setupComplete ? ( + + + Ready + + ) : ( + + + Not Initialized + + )} +
+ +
+

Proxy Account

+
+

{COMMISSIONS.KYC.proxyAccount}

+
+
+ +
+

+ Commission Members ({commissionMembers.length}) +

+ {commissionMembers.length === 0 ? ( +
+ No members yet +
+ ) : ( +
+ {commissionMembers.map((member, index) => ( +
+

{member}

+ {member === COMMISSIONS.KYC.proxyAccount && ( + KYC Proxy + )} +
+ ))} +
+ )} +
+ + {!setupComplete && ( + + + + Required: Initialize the commission before members can join. + This requires sudo privileges. + + + )} + + {setupComplete && ( +
+

Add Members

+
+ +
+
+ +
+
+ + +
+ + +
+
+

Our Focus Areas

+
    +
  • Decentralized Finance (DeFi)
  • +
  • NFTs and Gaming
  • +
  • Infrastructure and Tooling
  • +
  • Governance and DAOs
  • +
  • Privacy and Identity
  • +
  • Mobile and Web3 Applications
  • +
+
+
+ +
+

Funded Projects

+
+ {fundedProjects.map((project, index) => ( +
+
+
+ {`${project.name} +

{project.name}

+
+

{project.description}

+
+
+ ))} +
+
+
+ + ); +}; + +export default Grants; diff --git a/packages/apps/src/pages/Index.tsx b/packages/apps/src/pages/Index.tsx new file mode 100644 index 0000000..49d6a28 --- /dev/null +++ b/packages/apps/src/pages/Index.tsx @@ -0,0 +1,14 @@ + +import React from 'react'; +import AppLayout from '@/components/AppLayout'; +import { AppProvider } from '@/contexts/AppContext'; + +const Index: React.FC = () => { + return ( + + + + ); +}; + +export default Index; diff --git a/packages/apps/src/pages/Login.tsx b/packages/apps/src/pages/Login.tsx new file mode 100644 index 0000000..4067a42 --- /dev/null +++ b/packages/apps/src/pages/Login.tsx @@ -0,0 +1,393 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Separator } from '@/components/ui/separator'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Eye, EyeOff, Wallet, Mail, Lock, User, AlertCircle, ArrowLeft, UserPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +const Login: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { connectWallet, selectedAccount } = usePezkuwi(); + const { signIn, signUp } = useAuth(); + const [showPassword, setShowPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const [loginData, setLoginData] = useState({ + email: '', + password: '' + }); + + const [signupData, setSignupData] = useState({ + name: '', + email: '', + password: '', + confirmPassword: '', + referralCode: '' + }); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const { error } = await signIn(loginData.email, loginData.password, rememberMe); + + if (error) { + if (error.message?.includes('Invalid login credentials')) { + setError('Email or password is incorrect. Please try again.'); + } else { + setError(error instanceof Error ? error.message : 'Login failed. Please try again.'); + } + } else { + navigate('/'); + } + } catch { + setError('Login failed. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + if (signupData.password !== signupData.confirmPassword) { + setError('Passwords do not match'); + setLoading(false); + return; + } + + if (signupData.password.length < 8) { + setError('Password must be at least 8 characters'); + setLoading(false); + return; + } + + const { error } = await signUp( + signupData.email, + signupData.password, + signupData.name, + signupData.referralCode + ); + + if (error) { + setError(error.message); + } else { + navigate('/'); + } + } catch { + setError('Signup failed. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleWalletConnect = async () => { + setLoading(true); + setError(''); + try { + await connectWallet(); + if (selectedAccount) { + navigate('/'); + } else { + setError('Please select an account from your Pezkuwi.js extension'); + } + } catch (err) { + if (process.env.NODE_ENV !== 'production') console.error('Wallet connection failed:', err); + const errorMsg = err instanceof Error ? err.message : ''; + if (errorMsg?.includes('extension')) { + setError('Pezkuwi.js extension not found. Please install it first.'); + } else { + setError('Failed to connect wallet. Please try again.'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + + + + + PezkuwiChain + + + {t('login.subtitle', 'Access your governance account')} + + + + + + + {t('login.signin', 'Sign In')} + {t('login.signup', 'Sign Up')} + + + +
+
+ +
+ + setLoginData({...loginData, email: e.target.value})} + required + /> +
+
+ +
+ +
+ + setLoginData({...loginData, password: e.target.value})} + required + /> + +
+
+ +
+
+ setRememberMe(checked as boolean)} + /> + +
+ +
+ + {error && ( + + + {error} + + )} + + +
+
+ + +
+
+ +
+ + setSignupData({...signupData, name: e.target.value})} + required + /> +
+
+ +
+ +
+ + setSignupData({...signupData, email: e.target.value})} + required + /> +
+
+ +
+ +
+ + setSignupData({...signupData, password: e.target.value})} + required + /> + +
+
+ +
+ +
+ + setSignupData({...signupData, confirmPassword: e.target.value})} + required + /> +
+
+ +
+ +
+ + setSignupData({...signupData, referralCode: e.target.value})} + /> +
+

+ {t('login.referralDescription', 'If someone referred you, enter their code here')} +

+
+ + {error && ( + + + {error} + + )} + + +
+
+
+ +
+ +
+ + {t('login.or', 'Or continue with')} + +
+
+ + + +

+ {t('login.walletHint', 'Connect your Pezkuwi.js for instant access')} +

+
+ + +

+ {t('login.terms', 'By continuing, you agree to our')}{' '} + + {t('login.termsOfService', 'Terms of Service')} + {' '} + {t('login.and', 'and')}{' '} + + {t('login.privacyPolicy', 'Privacy Policy')} + +

+
+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/packages/apps/src/pages/NotFound.tsx b/packages/apps/src/pages/NotFound.tsx new file mode 100644 index 0000000..acdc21e --- /dev/null +++ b/packages/apps/src/pages/NotFound.tsx @@ -0,0 +1,27 @@ +import { useLocation } from "react-router-dom"; +import { useEffect } from "react"; + +const NotFound = () => { + const location = useLocation(); + + useEffect(() => { + if (process.env.NODE_ENV !== 'production') console.error( + "404 Error: User attempted to access non-existent route:", + location.pathname + ); + }, [location.pathname]); + + return ( +
+
+

404

+

Page not found

+ + Return to Home + +
+
+ ); +}; + +export default NotFound; diff --git a/packages/apps/src/pages/P2PDispute.tsx b/packages/apps/src/pages/P2PDispute.tsx new file mode 100644 index 0000000..2652cb0 --- /dev/null +++ b/packages/apps/src/pages/P2PDispute.tsx @@ -0,0 +1,608 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertTriangle, + ArrowLeft, + Clock, + CheckCircle, + XCircle, + Upload, + FileText, + Download, + User, + MessageSquare, + Calendar, + Scale, + ChevronRight, + X, +} from 'lucide-react'; +import { supabase } from '@/lib/supabase'; +import { toast } from 'sonner'; +import { formatAddress } from '@pezkuwi/utils/formatting'; + +interface DisputeDetails { + id: string; + trade_id: string; + opened_by: string; + reason: string; + description: string; + status: 'open' | 'under_review' | 'resolved' | 'closed'; + resolution?: 'release_to_buyer' | 'refund_to_seller' | 'split'; + resolution_notes?: string; + resolved_by?: string; + resolved_at?: string; + created_at: string; + trade?: { + id: string; + buyer_id: string; + seller_id: string; + buyer_wallet: string; + seller_wallet: string; + crypto_amount: string; + fiat_amount: string; + token: string; + fiat_currency: string; + status: string; + }; + opener?: { + id: string; + email: string; + }; +} + +interface Evidence { + id: string; + dispute_id: string; + uploaded_by: string; + evidence_type: string; + file_url: string; + description: string; + created_at: string; +} + +const STATUS_CONFIG: Record = { + open: { + color: 'bg-amber-500', + icon: , + label: 'Open', + }, + under_review: { + color: 'bg-blue-500', + icon: , + label: 'Under Review', + }, + resolved: { + color: 'bg-green-500', + icon: , + label: 'Resolved', + }, + closed: { + color: 'bg-gray-500', + icon: , + label: 'Closed', + }, +}; + +const RESOLUTION_LABELS: Record = { + release_to_buyer: 'Released to Buyer', + refund_to_seller: 'Refunded to Seller', + split: 'Split Decision', +}; + +export default function P2PDispute() { + const { disputeId } = useParams<{ disputeId: string }>(); + const navigate = useNavigate(); + useTranslation(); + const fileInputRef = useRef(null); + + const [dispute, setDispute] = useState(null); + const [evidence, setEvidence] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentUserId, setCurrentUserId] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + + useEffect(() => { + const fetchDispute = async () => { + if (!disputeId) return; + + try { + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + setCurrentUserId(user?.id || null); + + // Fetch dispute with trade info + const { data: disputeData, error: disputeError } = await supabase + .from('p2p_disputes') + .select(` + *, + trade:p2p_fiat_trades( + id, buyer_id, seller_id, buyer_wallet, seller_wallet, + crypto_amount, fiat_amount, token, fiat_currency, status + ) + `) + .eq('id', disputeId) + .single(); + + if (disputeError) throw disputeError; + setDispute(disputeData); + + // Fetch evidence + const { data: evidenceData, error: evidenceError } = await supabase + .from('p2p_dispute_evidence') + .select('*') + .eq('dispute_id', disputeId) + .order('created_at', { ascending: true }); + + if (!evidenceError && evidenceData) { + setEvidence(evidenceData); + } + } catch (error) { + console.error('Failed to fetch dispute:', error); + toast.error('Failed to load dispute details'); + } finally { + setIsLoading(false); + } + }; + + fetchDispute(); + + // Subscribe to dispute updates + const channel = supabase + .channel(`dispute-${disputeId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'p2p_disputes', + filter: `id=eq.${disputeId}`, + }, + (payload) => { + if (payload.new) { + setDispute((prev) => prev ? { ...prev, ...payload.new } : null); + } + } + ) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'p2p_dispute_evidence', + filter: `dispute_id=eq.${disputeId}`, + }, + (payload) => { + if (payload.new) { + setEvidence((prev) => [...prev, payload.new as Evidence]); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [disputeId]); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !dispute || !currentUserId) return; + + setIsUploading(true); + + try { + for (const file of Array.from(files)) { + if (file.size > 10 * 1024 * 1024) { + toast.error(`File ${file.name} is too large (max 10MB)`); + continue; + } + + const fileName = `disputes/${dispute.id}/${Date.now()}-${file.name}`; + const { data, error } = await supabase.storage + .from('p2p-evidence') + .upload(fileName, file); + + if (error) throw error; + + const { data: urlData } = supabase.storage + .from('p2p-evidence') + .getPublicUrl(data.path); + + // Insert evidence record + await supabase.from('p2p_dispute_evidence').insert({ + dispute_id: dispute.id, + uploaded_by: currentUserId, + evidence_type: file.type.startsWith('image/') ? 'screenshot' : 'document', + file_url: urlData.publicUrl, + description: file.name, + }); + } + + toast.success('Evidence uploaded successfully'); + } catch (error) { + console.error('Upload failed:', error); + toast.error('Failed to upload evidence'); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const isParticipant = dispute?.trade && + (dispute.trade.buyer_id === currentUserId || dispute.trade.seller_id === currentUserId); + + const isBuyer = dispute?.trade?.buyer_id === currentUserId; + const isSeller = dispute?.trade?.seller_id === currentUserId; + const isOpener = dispute?.opened_by === currentUserId; + + if (isLoading) { + return ( +
+ + + + + + + + + + + +
+ ); + } + + if (!dispute) { + return ( +
+ + + +

Dispute Not Found

+

+ The dispute you are looking for does not exist or you do not have access. +

+ +
+
+
+ ); + } + + const statusConfig = STATUS_CONFIG[dispute.status] || STATUS_CONFIG.open; + + return ( +
+ {/* Back Button */} + + + {/* Header Card */} + + +
+
+ + + Dispute #{dispute.id.slice(0, 8)} + + + + Opened {new Date(dispute.created_at).toLocaleDateString()} + +
+ + {statusConfig.icon} + {statusConfig.label} + +
+
+ + {/* Trade Info Summary */} + {dispute.trade && ( +
+
+
+

Related Trade

+

+ {dispute.trade.crypto_amount} {dispute.trade.token} for{' '} + {dispute.trade.fiat_amount} {dispute.trade.fiat_currency} +

+
+ + + +
+
+
+ Buyer:{' '} + + {formatAddress(dispute.trade.buyer_wallet, 6, 4)} + {isBuyer && ' (You)'} + +
+
+ Seller:{' '} + + {formatAddress(dispute.trade.seller_wallet, 6, 4)} + {isSeller && ' (You)'} + +
+
+
+ )} + + {/* Dispute Details */} +
+

+ + Dispute Reason +

+ + {dispute.reason.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} + +

+ {dispute.description} +

+

+ Opened by: {isOpener ? 'You' : (isBuyer ? 'Seller' : 'Buyer')} +

+
+ + {/* Resolution (if resolved) */} + {dispute.status === 'resolved' && dispute.resolution && ( +
+

+ + Resolution +

+

+ {RESOLUTION_LABELS[dispute.resolution]} +

+ {dispute.resolution_notes && ( +

+ {dispute.resolution_notes} +

+ )} + {dispute.resolved_at && ( +

+ Resolved on {new Date(dispute.resolved_at).toLocaleString()} +

+ )} +
+ )} +
+
+ + {/* Evidence Section */} + + +
+ Evidence + {isParticipant && dispute.status !== 'resolved' && ( +
+ + +
+ )} +
+
+ + {evidence.length === 0 ? ( +
+ +

No evidence submitted yet

+ {isParticipant && dispute.status !== 'resolved' && ( +

+ Upload screenshots, receipts, or documents to support your case +

+ )} +
+ ) : ( +
+ {evidence.map((item) => { + const isImage = item.evidence_type === 'screenshot' || + item.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i); + const isMyEvidence = item.uploaded_by === currentUserId; + + return ( +
+ {isImage ? ( + {item.description} setSelectedImage(item.file_url)} + /> + ) : ( +
+ +
+ )} +
+

{item.description}

+

+ {isMyEvidence ? 'You' : ( + item.uploaded_by === dispute.trade?.buyer_id ? 'Buyer' : 'Seller' + )} +

+
+ + + +
+ ); + })} +
+ )} +
+
+ + {/* Status Timeline */} + + + Status Timeline + + +
+ {/* Opened */} +
+
+ +
+
+

Dispute Opened

+

+ {new Date(dispute.created_at).toLocaleString()} +

+
+
+ + {/* Under Review (if applicable) */} + {(dispute.status === 'under_review' || dispute.status === 'resolved') && ( +
+
+ +
+
+

Under Review

+

+ Admin is reviewing the case +

+
+
+ )} + + {/* Resolved (if applicable) */} + {dispute.status === 'resolved' && ( +
+
+ +
+
+

Resolved

+

+ {dispute.resolved_at && new Date(dispute.resolved_at).toLocaleString()} +

+
+
+ )} + + {/* Pending steps */} + {dispute.status === 'open' && ( + <> +
+
+ +
+
+

Under Review

+

Pending

+
+
+
+
+ +
+
+

Resolution

+

Pending

+
+
+ + )} +
+
+
+ + {/* Help Card */} + + +
+ +
+

Need Help?

+

+ Our support team typically responds within 24-48 hours. +

+
+ +
+
+
+ + {/* Image Lightbox */} + {selectedImage && ( +
setSelectedImage(null)} + > + + Evidence e.stopPropagation()} + /> +
+ )} +
+ ); +} diff --git a/packages/apps/src/pages/P2PMerchantDashboard.tsx b/packages/apps/src/pages/P2PMerchantDashboard.tsx new file mode 100644 index 0000000..da3b9b7 --- /dev/null +++ b/packages/apps/src/pages/P2PMerchantDashboard.tsx @@ -0,0 +1,854 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { supabase } from '@/lib/supabase'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { toast } from 'sonner'; +import { MerchantTierBadge } from '@/components/p2p/MerchantTierBadge'; +import { MerchantApplication } from '@/components/p2p/MerchantApplication'; +import { CreateAd } from '@/components/p2p/CreateAd'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + BarChart, + Bar +} from 'recharts'; +import { + ArrowLeft, + BarChart3, + Clock, + Crown, + DollarSign, + Edit, + Loader2, + MessageSquare, + Pause, + Play, + Plus, + Settings, + ShoppingBag, + Star, + TrendingUp, + Trash2 +} from 'lucide-react'; + +// Types +interface MerchantStats { + total_volume_30d: number; + total_trades_30d: number; + buy_volume_30d: number; + sell_volume_30d: number; + completion_rate_30d: number; + avg_release_time_minutes: number; + avg_payment_time_minutes: number; + total_volume_lifetime: number; + total_trades_lifetime: number; +} + +interface ActiveAd { + id: string; + token: string; + fiat_currency: string; + amount_crypto: number; + remaining_amount: number; + fiat_amount: number; + price_per_unit: number; + status: string; + created_at: string; + is_featured: boolean; + ad_type: 'buy' | 'sell'; + min_order_amount?: number; + max_order_amount?: number; + time_limit_minutes: number; +} + +interface MerchantTier { + tier: 'lite' | 'super' | 'diamond'; + max_pending_orders: number; + max_order_amount: number; + featured_ads_allowed: number; +} + +interface ChartDataPoint { + date: string; + volume: number; + trades: number; +} + +export default function P2PMerchantDashboard() { + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [tierInfo, setTierInfo] = useState(null); + const [activeAds, setActiveAds] = useState([]); + const [chartData, setChartData] = useState([]); + const [autoReplyOpen, setAutoReplyOpen] = useState(false); + const [autoReplyMessage, setAutoReplyMessage] = useState(''); + const [savingAutoReply, setSavingAutoReply] = useState(false); + const [createAdOpen, setCreateAdOpen] = useState(false); + + // Edit ad state + const [editAdOpen, setEditAdOpen] = useState(false); + const [editingAd, setEditingAd] = useState(null); + const [editFiatAmount, setEditFiatAmount] = useState(''); + const [editMinOrder, setEditMinOrder] = useState(''); + const [editMaxOrder, setEditMaxOrder] = useState(''); + const [savingEdit, setSavingEdit] = useState(false); + + // Fetch merchant data + const fetchData = useCallback(async () => { + setLoading(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + navigate('/login'); + return; + } + + // Fetch stats + const { data: statsData } = await supabase + .from('p2p_merchant_stats') + .select('*') + .eq('user_id', user.id) + .single(); + + if (statsData) { + setStats(statsData); + } + + // Fetch tier info + const { data: tierData } = await supabase + .from('p2p_merchant_tiers') + .select('tier, max_pending_orders, max_order_amount, featured_ads_allowed') + .eq('user_id', user.id) + .single(); + + if (tierData) { + setTierInfo(tierData); + } + + // Fetch active ads + const { data: adsData } = await supabase + .from('p2p_fiat_offers') + .select('*') + .eq('seller_id', user.id) + .in('status', ['open', 'paused']) + .order('created_at', { ascending: false }); + + if (adsData) { + setActiveAds(adsData); + } + + // Fetch chart data - last 30 days trades + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const { data: tradesData } = await supabase + .from('p2p_fiat_trades') + .select('created_at, fiat_amount, status') + .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .gte('created_at', thirtyDaysAgo.toISOString()) + .order('created_at', { ascending: true }); + + // Group trades by day + const tradesByDay: Record = {}; + + // Initialize all 30 days with 0 + for (let i = 29; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const dateKey = date.toISOString().split('T')[0]; + tradesByDay[dateKey] = { volume: 0, trades: 0 }; + } + + // Fill in actual data + tradesData?.forEach((trade) => { + const dateKey = new Date(trade.created_at).toISOString().split('T')[0]; + if (tradesByDay[dateKey]) { + tradesByDay[dateKey].volume += trade.fiat_amount || 0; + tradesByDay[dateKey].trades += 1; + } + }); + + // Convert to array for chart + const chartDataArray: ChartDataPoint[] = Object.entries(tradesByDay).map(([date, data]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + volume: data.volume, + trades: data.trades + })); + + setChartData(chartDataArray); + } catch (error) { + console.error('Error fetching merchant data:', error); + } finally { + setLoading(false); + } + }, [navigate]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Toggle ad status + const toggleAdStatus = async (adId: string, currentStatus: string) => { + try { + const newStatus = currentStatus === 'open' ? 'paused' : 'open'; + const { error } = await supabase + .from('p2p_fiat_offers') + .update({ status: newStatus }) + .eq('id', adId); + + if (error) throw error; + + toast.success(`Ad ${newStatus === 'open' ? 'activated' : 'paused'}`); + fetchData(); + } catch (error) { + console.error('Error toggling ad status:', error); + toast.error('Failed to update ad status'); + } + }; + + // Delete ad + const deleteAd = async (adId: string) => { + if (!confirm('Are you sure you want to delete this ad?')) return; + + try { + const { error } = await supabase + .from('p2p_fiat_offers') + .delete() + .eq('id', adId); + + if (error) throw error; + + toast.success('Ad deleted successfully'); + fetchData(); + } catch (error) { + console.error('Error deleting ad:', error); + toast.error('Failed to delete ad'); + } + }; + + // Open edit modal with ad data + const openEditModal = (ad: ActiveAd) => { + setEditingAd(ad); + setEditFiatAmount(ad.fiat_amount.toString()); + setEditMinOrder(ad.min_order_amount?.toString() || ''); + setEditMaxOrder(ad.max_order_amount?.toString() || ''); + setEditAdOpen(true); + }; + + // Save ad edits + const saveAdEdit = async () => { + if (!editingAd) return; + + const fiatAmt = parseFloat(editFiatAmount); + if (!fiatAmt || fiatAmt <= 0) { + toast.error('Invalid fiat amount'); + return; + } + + setSavingEdit(true); + try { + const updateData: Record = { + fiat_amount: fiatAmt, + min_order_amount: editMinOrder ? parseFloat(editMinOrder) : null, + max_order_amount: editMaxOrder ? parseFloat(editMaxOrder) : null, + }; + + const { error } = await supabase + .from('p2p_fiat_offers') + .update(updateData) + .eq('id', editingAd.id); + + if (error) throw error; + + toast.success('Ad updated successfully'); + setEditAdOpen(false); + setEditingAd(null); + fetchData(); + } catch (error) { + console.error('Error updating ad:', error); + toast.error('Failed to update ad'); + } finally { + setSavingEdit(false); + } + }; + + // Save auto-reply message + const saveAutoReply = async () => { + setSavingAutoReply(true); + try { + // Save to all active ads + const { error } = await supabase + .from('p2p_fiat_offers') + .update({ auto_reply_message: autoReplyMessage }) + .eq('seller_id', (await supabase.auth.getUser()).data.user?.id); + + if (error) throw error; + + toast.success('Auto-reply message saved'); + setAutoReplyOpen(false); + } catch (error) { + console.error('Error saving auto-reply:', error); + toast.error('Failed to save auto-reply'); + } finally { + setSavingAutoReply(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ + Merchant Dashboard +

+

+ Manage your P2P trading business +

+
+
+ {tierInfo && } +
+ + + + + + Overview + + + + My Ads + + + + Upgrade Tier + + + + Settings + + + + {/* Overview Tab */} + + {/* Stats Cards */} +
+ + +
+
+

30-Day Volume

+

+ ${stats?.total_volume_30d?.toLocaleString() || '0'} +

+
+ +
+
+
+ + + +
+
+

30-Day Trades

+

{stats?.total_trades_30d || 0}

+
+ +
+
+
+ + + +
+
+

Completion Rate

+

+ {stats?.completion_rate_30d?.toFixed(1) || '0'}% +

+
+ +
+
+
+ + + +
+
+

Avg Release Time

+

+ {stats?.avg_release_time_minutes || 0}m +

+
+ +
+
+
+
+ + {/* Charts */} +
+ {/* Volume Chart */} + + + Volume Trend + Last 30 days trading volume + + + + + + + + + + + + + + + {/* Trades Chart */} + + + Trade Count + Daily trades over last 30 days + + + + + + + + + + + + + +
+ + {/* Quick Stats */} + + + Lifetime Statistics + + +
+
+

+ ${stats?.total_volume_lifetime?.toLocaleString() || '0'} +

+

Total Volume

+
+
+

{stats?.total_trades_lifetime || 0}

+

Total Trades

+
+
+

+ ${stats?.buy_volume_30d?.toLocaleString() || '0'} +

+

Buy Volume (30d)

+
+
+

+ ${stats?.sell_volume_30d?.toLocaleString() || '0'} +

+

Sell Volume (30d)

+
+
+
+
+
+ + {/* My Ads Tab */} + +
+

+ Active Advertisements ({activeAds.length}) +

+ +
+ + {activeAds.length === 0 ? ( + + + +

+ You don't have any active ads yet. +

+ +
+
+ ) : ( +
+ {activeAds.map((ad) => ( + + +
+
+
+
+ + {ad.status.toUpperCase()} + + + Sell {ad.token} for {ad.fiat_currency} + + {ad.is_featured && ( + + + Featured + + )} +
+

+ {ad.remaining_amount} / {ad.amount_crypto} {ad.token} remaining +

+
+
+ +
+

+ {ad.price_per_unit?.toFixed(2)} {ad.fiat_currency}/{ad.token} +

+

+ Total: {ad.fiat_amount?.toLocaleString()} {ad.fiat_currency} +

+
+ +
+ + + +
+
+
+
+ ))} +
+ )} + + {/* Tier limits info */} + {tierInfo && ( + + +
+ + Active ads: {activeAds.filter(a => a.status === 'open').length} / {tierInfo.max_pending_orders} + + + Max order: ${tierInfo.max_order_amount.toLocaleString()} + + + Featured ads: {activeAds.filter(a => a.is_featured).length} / {tierInfo.featured_ads_allowed} + +
+
+
+ )} +
+ + {/* Upgrade Tier Tab */} + + + + + {/* Settings Tab */} + + {/* Auto-reply */} + + + + + Auto-Reply Message + + + Automatically send this message when someone starts a trade with you + + + + + + + + {/* Notification Settings */} + + + Notification Settings + + +
+
+

New Order Notifications

+

+ Get notified when someone accepts your offer +

+
+ +
+
+
+

Payment Notifications

+

+ Get notified when buyer marks payment as sent +

+
+ +
+
+
+

Chat Notifications

+

+ Get notified for new chat messages +

+
+ +
+
+
+ + {/* Danger Zone */} + + + Danger Zone + + +
+
+

Pause All Ads

+

+ Temporarily pause all your active advertisements +

+
+ +
+
+
+
+
+ + {/* Auto-reply Dialog */} + + + + Auto-Reply Message + + This message will be automatically sent when someone starts a trade with you. + + +
+
+ +