diff --git a/.vscode/settings.json b/.vscode/settings.json index 1b9ca60d..50685fb6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,41 +18,6 @@ "editor.defaultFormatter": "biomejs.biome" }, "biome.enabled": true, - "cSpell.words": [ - "biomejs", - "bunx", - "crowdloan", - "fontawesome", - "fortawesome", - "karura", - "khala", - "lefthook", - "moonbase", - "moonriver", - "movr", - "orml", - "parachain", - "phala", - "polkadot", - "preinstall", - "readystate", - "roboto", - "staker", - "stakers", - "subbridge", - "Subscan", - "subsquid", - "svgs", - "sygma", - "talismn", - "tanstack", - "tokenomic", - "unsub", - "xtokens", - "connectkit", - "wagmi", - "walletconnect" - ], "editor.codeActionsOnSave": { "quickfix.biome": "explicit", "source.addMissingImports": "explicit", diff --git a/README.md b/README.md index f6d9feff..df290e2c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,3 @@ -# Phala Apps +# Phala Network Apps [![Discord](https://img.shields.io/discord/697726436211163147?color=%235865F2&label=discord&style=for-the-badge)](https://discord.gg/phala-network) - -Monorepo for apps in phala. - -## Apps - -### [Phala App](/apps/app) - -### [SubBridge](/apps/subbridge) diff --git a/apps/app/public/apple-touch-icon.png b/apps/app/app/apple-icon.png similarity index 100% rename from apps/app/public/apple-touch-icon.png rename to apps/app/app/apple-icon.png diff --git a/apps/app/app/content.tsx b/apps/app/app/content.tsx new file mode 100644 index 00000000..1c95cd6c --- /dev/null +++ b/apps/app/app/content.tsx @@ -0,0 +1,213 @@ +'use client' + +import {Box, Divider, Grid, Stack, Typography} from '@mui/material' +import phaIcon from '@phala/ui/icons/asset/pha.png' +import vphaIcon from '@phala/ui/icons/asset/vpha.png' +import {useAppKitAccount, useAppKitNetwork} from '@reown/appkit/react' +import Decimal from 'decimal.js' +import {useMemo} from 'react' +import {erc20Abi, formatUnits} from 'viem' +import {mainnet} from 'viem/chains' +import {useReadContract} from 'wagmi' + +import AssetCard from '@/components/asset-card' +import PortfolioSummary from '@/components/portfolio-summary' +import QuickActions from '@/components/quick-actions' +import { + ethChain, + L2_VPHA_CONTRACT_ADDRESS, + PHA_CONTRACT_ADDRESS, + VAULT_CONTRACT_ADDRESS, +} from '@/config' +import {useRewardRate, useSharePrice, useTotalAssets} from '@/hooks/staking' +import {phalaNetwork, toAddress} from '@/lib/wagmi' + +export default function HomeContent() { + const {address: rawAddress, isConnected} = useAppKitAccount() + const {chainId} = useAppKitNetwork() + const address = toAddress(rawAddress) + const sharePrice = useSharePrice() + const totalAssets = useTotalAssets() + const rewardRate = useRewardRate() + + const isValidConnection = isConnected && chainId === ethChain.id + + const stakingApr = useMemo(() => { + if (rewardRate == null || !totalAssets) { + return null + } + return Decimal.mul(rewardRate.toString(), 365 * 24 * 60 * 60).div( + totalAssets.toString(), + ) + }, [rewardRate, totalAssets]) + + const {data: l1PhaBalance} = useReadContract({ + address: PHA_CONTRACT_ADDRESS, + abi: erc20Abi, + functionName: 'balanceOf', + args: address && [address], + chainId: mainnet.id, + query: { + enabled: isValidConnection && Boolean(address), + refetchInterval: 3_000, + }, + }) + + const {data: l1VphaBalance} = useReadContract({ + address: VAULT_CONTRACT_ADDRESS, + abi: erc20Abi, + functionName: 'balanceOf', + args: address && [address], + chainId: mainnet.id, + query: { + enabled: isValidConnection && Boolean(address), + refetchInterval: 3_000, + }, + }) + + const {data: l2VphaBalance} = useReadContract({ + address: L2_VPHA_CONTRACT_ADDRESS, + abi: erc20Abi, + functionName: 'balanceOf', + args: address && [address], + chainId: phalaNetwork.id, + query: { + enabled: isValidConnection && Boolean(address), + refetchInterval: 3_000, + }, + }) + + const l1VphaInPha = useMemo(() => { + if (l1VphaBalance == null || sharePrice == null) return null + return (l1VphaBalance * sharePrice) / BigInt(1e18) + }, [l1VphaBalance, sharePrice]) + + const l2VphaInPha = useMemo(() => { + if (l2VphaBalance == null || sharePrice == null) return null + return (l2VphaBalance * sharePrice) / BigInt(1e18) + }, [l2VphaBalance, sharePrice]) + + const totalVphaBalance = useMemo(() => { + if (l1VphaBalance == null && l2VphaBalance == null) return null + return (l1VphaBalance ?? 0n) + (l2VphaBalance ?? 0n) + }, [l1VphaBalance, l2VphaBalance]) + + const totalVphaInPha = useMemo(() => { + if (l1VphaInPha == null && l2VphaInPha == null) return null + return (l1VphaInPha ?? 0n) + (l2VphaInPha ?? 0n) + }, [l1VphaInPha, l2VphaInPha]) + + const totalPhaValue = useMemo(() => { + if (!isValidConnection) return null + const pha = l1PhaBalance ?? 0n + const vpha = totalVphaInPha ?? 0n + return pha + vpha + }, [isValidConnection, l1PhaBalance, totalVphaInPha]) + + return ( + + + + + + + + + + + Your Assets + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/app/app/gpu-mining/content.tsx b/apps/app/app/gpu-mining/content.tsx new file mode 100644 index 00000000..254ed349 --- /dev/null +++ b/apps/app/app/gpu-mining/content.tsx @@ -0,0 +1,88 @@ +'use client' + +import OpenInNew from '@mui/icons-material/OpenInNew' +import {Box, Button, Link, Paper, Stack, Typography} from '@mui/material' +import Image from 'next/image' + +import dephyLogo from '@/assets/dephy.svg' + +export default function GpuMiningContent() { + return ( + + GPU Mining + + + + + Start mining with your GPU on the Phala Network. Access the Mining + Portal to manage your stake pools and monitor your mining + operations. + + + + + + + + + + + + Powered by + + DePHY + + + + ) +} diff --git a/apps/app/app/gpu-mining/page.tsx b/apps/app/app/gpu-mining/page.tsx new file mode 100644 index 00000000..72240067 --- /dev/null +++ b/apps/app/app/gpu-mining/page.tsx @@ -0,0 +1,13 @@ +import type {Metadata} from 'next' + +import GpuMiningContent from './content' + +export const metadata: Metadata = { + title: 'GPU Mining', + description: + 'Start mining with your GPU on the Phala Network. Manage stake pools and monitor mining operations.', +} + +export default function GpuMiningPage() { + return +} diff --git a/apps/app/public/android-chrome-192x192.png b/apps/app/app/icon-192.png similarity index 100% rename from apps/app/public/android-chrome-192x192.png rename to apps/app/app/icon-192.png diff --git a/apps/app/public/android-chrome-512x512.png b/apps/app/app/icon-512.png similarity index 100% rename from apps/app/public/android-chrome-512x512.png rename to apps/app/app/icon-512.png diff --git a/apps/app/public/favicon.ico b/apps/app/app/icon.ico similarity index 100% rename from apps/app/public/favicon.ico rename to apps/app/app/icon.ico diff --git a/apps/app/pages/khala-assets.tsx b/apps/app/app/khala-assets/content.tsx similarity index 96% rename from apps/app/pages/khala-assets.tsx rename to apps/app/app/khala-assets/content.tsx index 32f93e11..a304eb66 100644 --- a/apps/app/pages/khala-assets.tsx +++ b/apps/app/app/khala-assets/content.tsx @@ -1,14 +1,16 @@ -import ClaimAssets from '@/components/ClaimAssets' -import Title from '@/components/Title' +'use client' + import {Link, Paper, Stack, Typography} from '@mui/material' import khalaIcon from '@phala/ui/icons/chain/khala.png' import phalaIcon from '@phala/ui/icons/chain/phala.png' import Image from 'next/image' -const Page = () => { +import ClaimAssets from '@/components/claim-assets' +import PolkadotProvider from '@/components/polkadot-provider' + +export default function KhalaAssetsContent() { return ( - <> - Claim Phala/Khala Assets + Claim Phala/Khala Assets @@ -150,8 +152,6 @@ const Page = () => { - + ) } - -export default Page diff --git a/apps/app/app/khala-assets/page.tsx b/apps/app/app/khala-assets/page.tsx new file mode 100644 index 00000000..985d8ae0 --- /dev/null +++ b/apps/app/app/khala-assets/page.tsx @@ -0,0 +1,13 @@ +import type {Metadata} from 'next' + +import KhalaAssetsContent from './content' + +export const metadata: Metadata = { + title: 'Claim Phala/Khala Assets', + description: + 'Claim your Phala and Khala assets on Ethereum after chain termination.', +} + +export default function KhalaAssetsPage() { + return +} diff --git a/apps/app/app/layout.tsx b/apps/app/app/layout.tsx new file mode 100644 index 00000000..8436414f --- /dev/null +++ b/apps/app/app/layout.tsx @@ -0,0 +1,38 @@ +import type {Metadata} from 'next' +import {Montserrat} from 'next/font/google' +import {headers} from 'next/headers' +import type {ReactNode} from 'react' + +import Providers from './providers' + +const montserrat = Montserrat({ + weight: ['300', '400', '500', '600', '700'], + subsets: ['latin'], + display: 'swap', + fallback: ['Helvetica', 'Arial', 'sans-serif'], +}) + +export const metadata: Metadata = { + title: { + default: 'Phala Network App', + template: '%s | Phala Network App', + }, + description: 'Phala Network App - Staking, GPU Mining', +} + +export default async function RootLayout({children}: {children: ReactNode}) { + const headersObj = await headers() + const cookies = headersObj.get('cookie') + + return ( + + + + + + + {children} + + + ) +} diff --git a/apps/app/app/manifest.ts b/apps/app/app/manifest.ts new file mode 100644 index 00000000..e39d07b0 --- /dev/null +++ b/apps/app/app/manifest.ts @@ -0,0 +1,25 @@ +import type {MetadataRoute} from 'next' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'Phala App', + short_name: 'Phala App', + description: 'Phala Network App - Staking, GPU Mining', + start_url: '/', + display: 'standalone', + background_color: '#000000', + theme_color: '#000000', + icons: [ + { + src: '/icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + ], + } +} diff --git a/apps/app/app/page.tsx b/apps/app/app/page.tsx new file mode 100644 index 00000000..e50067d9 --- /dev/null +++ b/apps/app/app/page.tsx @@ -0,0 +1,13 @@ +import type {Metadata} from 'next' + +import HomeContent from './content' + +export const metadata: Metadata = { + title: 'Portfolio', + description: + 'Manage your PHA assets - stake, bridge, swap and claim your Phala tokens', +} + +export default function HomePage() { + return +} diff --git a/apps/app/app/providers.tsx b/apps/app/app/providers.tsx new file mode 100644 index 00000000..2a941067 --- /dev/null +++ b/apps/app/app/providers.tsx @@ -0,0 +1,77 @@ +'use client' + +import { + CssBaseline, + GlobalStyles, + ThemeProvider as MuiThemeProvider, +} from '@mui/material' +import {AppRouterCacheProvider} from '@mui/material-nextjs/v16-appRouter' +import {QueryCache, QueryClient} from '@tanstack/react-query' +import {ReactQueryDevtools} from '@tanstack/react-query-devtools' +import Decimal from 'decimal.js' +import {Provider as JotaiProvider} from 'jotai' +import type {ReactNode} from 'react' +import {useState} from 'react' +import {SWRConfig} from 'swr' + +import Layout from '@/components/layout' +import SnackbarProvider from '@/components/snackbar-provider' +import {Web3Provider} from '@/components/web3-provider' +import {globalStyles} from '@/lib/styles' +import {theme} from '@/lib/theme' + +Decimal.set({toExpNeg: -9e15, toExpPos: 9e15, precision: 50}) + +// @ts-ignore +BigInt.prototype.toJSON = function () { + return this.toString() +} + +export default function Providers({ + children, + cookies, +}: { + children: ReactNode + cookies: string | null +}) { + const [queryClient] = useState( + () => + new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + console.error(error) + }, + }), + }), + ) + + return ( + { + if (process.env.NODE_ENV === 'development') { + console.error(key, error) + } + }, + }} + > + + + + + + + + + + {children} + + + + + + + + + ) +} diff --git a/apps/app/app/staking/content.tsx b/apps/app/app/staking/content.tsx new file mode 100644 index 00000000..9452a368 --- /dev/null +++ b/apps/app/app/staking/content.tsx @@ -0,0 +1,44 @@ +'use client' + +import {Box, Chip} from '@mui/material' +import Image from 'next/image' + +import poweredBySlpx from '@/assets/powered_by_slpx.png' +import PageHeader from '@/components/page-header' +import Staking from '@/components/staking' + +export default function StakingContent() { + return ( + <> + + Staking + + + } + > + + Powered by SLPx + + + + + ) +} diff --git a/apps/app/app/staking/page.tsx b/apps/app/app/staking/page.tsx new file mode 100644 index 00000000..e03f1392 --- /dev/null +++ b/apps/app/app/staking/page.tsx @@ -0,0 +1,12 @@ +import type {Metadata} from 'next' + +import StakingContent from './content' + +export const metadata: Metadata = { + title: 'Staking', + description: 'Stake PHA tokens on Ethereum with SLPx', +} + +export default function StakingPage() { + return +} diff --git a/apps/app/assets/dephy.svg b/apps/app/assets/dephy.svg new file mode 100644 index 00000000..23774e6c --- /dev/null +++ b/apps/app/assets/dephy.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/apps/app/components/ClaimAssets.tsx b/apps/app/components/ClaimAssets.tsx deleted file mode 100644 index 220436eb..00000000 --- a/apps/app/components/ClaimAssets.tsx +++ /dev/null @@ -1,564 +0,0 @@ -import khalaClaimerAbi from '@/assets/khala_claimer_abi' -import phalaClaimerAbi from '@/assets/phala_claimer_abi' -import Property from '@/components/Property' -import SwitchChainButton from '@/components/SwitchChainButton' -import { - KHALA_CLAIMER_CONTRACT_ADDRESS, - PHALA_CLAIMER_CONTRACT_ADDRESS, - explorerUrl, -} from '@/config' -import { - type ChainType, - khalaAssetsApi, - phalaAssetsApi, - useAssetsQuery, - useClaimStatus, -} from '@/hooks/khalaAssets' -import {useSharePrice} from '@/hooks/staking' -import {useAutoSwitchChain} from '@/hooks/useAutoSwitchChain' -import {walletDialogOpenAtom} from '@/store/ui' -import {CheckCircleOutline, ContentCopy} from '@mui/icons-material' -import { - Alert, - Box, - Button, - Divider, - Link, - Paper, - Skeleton, - Stack, - Step, - StepLabel, - Stepper, - TextField, - Typography, -} from '@mui/material' -import {toCurrency, trimAddress, validateAddress} from '@phala/lib' -import {polkadotAccountAtom} from '@phala/store' -import {decodeAddress} from '@polkadot/keyring' -import Identicon from '@polkadot/react-identicon' -import type {Signer} from '@polkadot/types/types' -import {stringToHex, u8aToHex} from '@polkadot/util' -import {ConnectButton} from '@rainbow-me/rainbowkit' -import Decimal from 'decimal.js' -import {useAtom, useSetAtom} from 'jotai' -import NextLink from 'next/link' -import {useSnackbar} from 'notistack' -import {useEffect, useMemo, useState} from 'react' -import type {Hex} from 'viem' -import {useAccount, useWaitForTransactionReceipt, useWriteContract} from 'wagmi' - -const Steps = () => { - return ( - - - Sign with account - - - Connect Ethereum wallet - - - Claim PHA on Ethereum - - - ) -} - -export const CheckAssets = ({ - onCheck, - chain, -}: { - onCheck: (address: string) => void - chain: ChainType -}) => { - const {enqueueSnackbar} = useSnackbar() - const [checkAddressInput, setCheckAddressInput] = useState('') - const chainLabel = chain.charAt(0).toUpperCase() + chain.slice(1) - - const handleCheck = (e: React.FormEvent) => { - e.preventDefault() - if (validateAddress(checkAddressInput)) { - onCheck(checkAddressInput) - } else { - enqueueSnackbar(`Invalid ${chainLabel} address`, {variant: 'error'}) - } - } - return ( - <> - - { - setCheckAddressInput(e.target.value) - }} - /> - - - - ) -} - -// Phala staking rewards exchange rate (vPHA to PHA) -// This rate is fixed at the snapshot when Phala chain stopped -const PHALA_SHARE_PRICE = new Decimal('42148385.793480375048').div( - '35407137.584585511432035582', -) - -const ClaimAssets = ({chain}: {chain: ChainType}) => { - const {enqueueSnackbar} = useSnackbar() - const setWalletDialogOpen = useSetAtom(walletDialogOpenAtom) - const [isSigning, setIsSigning] = useState(false) - const [checkAddress, setCheckAddress] = useState( - undefined, - ) - useAutoSwitchChain() - const sharePrice = useSharePrice() - const {address: ethAddress, chain: ethChain} = useAccount() - const [polkadotAccount] = useAtom(polkadotAccountAtom) - const address = checkAddress ?? polkadotAccount?.address - const {data} = useAssetsQuery(address, chain) - const h160Address = useMemo(() => { - if (address == null) { - return undefined - } - const publicKey = decodeAddress(address) - const h160 = u8aToHex(publicKey).slice(0, 42) as Hex - return h160 - }, [address]) - const {claimed, log, refetch} = useClaimStatus(h160Address, chain) - const logData = useMemo(() => { - if (log == null) { - return undefined - } - - const txHash = log.transactionHash_ - return { - url: `${explorerUrl}/tx/${txHash}`, - hash: txHash, - trimmedHash: trimAddress(txHash, 6, 6), - receiver: log.receiver, - l1Receiver: 'l1Receiver' in log ? log.l1Receiver : undefined, - timestamp: log.timestamp_, - } - }, [log]) - - const {data: hash, writeContract, isPending, reset} = useWriteContract() - const claimResult = useWaitForTransactionReceipt({hash}) - - const isLoading = isPending || claimResult.isLoading || isSigning - - const isClaimable = useMemo(() => { - if (data == null) { - return false - } - return new Decimal(data.free).add(data.staked).gt(0) - }, [data]) - - useEffect(() => { - if (claimResult.data?.status === 'success') { - enqueueSnackbar('Claimed successfully', {variant: 'success'}) - reset() - refetch() - } - }, [claimResult.data?.status, enqueueSnackbar, reset, refetch]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!ethAddress) { - return - } - const receiver = ethAddress - const {signRaw} = polkadotAccount?.signer as Signer - if (signRaw == null || polkadotAccount == null || h160Address == null) { - return - } - let polkadotSignature: Hex - try { - setIsSigning(true) - const {signature} = await signRaw({ - address: polkadotAccount.address, - data: stringToHex( - `${chain.charAt(0).toUpperCase() + chain.slice(1)} Asset Receiver: ${receiver}`, - ), - type: 'bytes', - }) - polkadotSignature = signature - } catch (e) { - setIsSigning(false) - return - } - - try { - const api = chain === 'khala' ? khalaAssetsApi : phalaAssetsApi - const {h160, free, staked, signature} = await api - .url('/claim') - .post({ - address: polkadotAccount.address, - signature: polkadotSignature, - receiver, - }) - .json<{h160: Hex; free: string; staked: string; signature: Hex}>() - - const contractAddress = - chain === 'khala' - ? KHALA_CLAIMER_CONTRACT_ADDRESS - : PHALA_CLAIMER_CONTRACT_ADDRESS - - const contractAbi = chain === 'khala' ? khalaClaimerAbi : phalaClaimerAbi - - writeContract({ - abi: contractAbi, - address: contractAddress, - functionName: 'claim', - args: [h160, BigInt(free), BigInt(staked), receiver, signature], - }) - } catch (e) { - return enqueueSnackbar('Failed to claim', {variant: 'error'}) - } finally { - setIsSigning(false) - } - } - - const onSwitchAccount = () => { - if (polkadotAccount == null) { - setCheckAddress(undefined) - } else { - setWalletDialogOpen(true) - } - } - - // Convert vPHA delegation to real PHA for Phala chain - const delegationInPHA = useMemo(() => { - if (chain !== 'phala' || data?.staked == null) { - return undefined - } - return new Decimal(data.staked).mul(PHALA_SHARE_PRICE) - }, [chain, data?.staked]) - - const rewards = useMemo(() => { - if (data?.staked == null) { - return - } - if (chain === 'phala') { - // For Phala, use current share price minus snapshot share price - if (sharePrice == null) { - return - } - const currentRate = new Decimal(sharePrice.toString()).div(1e18) - const reward = currentRate.minus(PHALA_SHARE_PRICE).mul(data.staked) - // Ensure rewards are non-negative (handle rounding errors) - return Decimal.max(0, reward) - } - // For Khala, use current share price minus 1 - if (sharePrice == null) { - return - } - const reward = new Decimal(sharePrice.toString()) - .div(1e18) - .minus(1) - .mul(data.staked) - // Ensure rewards are non-negative (handle rounding errors) - return Decimal.max(0, reward) - }, [sharePrice, data?.staked, chain]) - - const total = useMemo(() => { - if (data == null || rewards == null) { - return - } - // For Phala, use converted delegation amount; for Khala, use staked amount as is - const stakedAmount = delegationInPHA ?? data.staked - return Decimal.add(data.free, stakedAmount).add(rewards) - }, [data, rewards, delegationInPHA]) - - const chainLabel = chain.charAt(0).toUpperCase() + chain.slice(1) - - if (address == null) { - return ( - - - Connect wallet to claim - - - - - - - - Check your {chainLabel} account - - - - - ) - } - - return ( - <> - - {total == null ? '-' : toCurrency(total)} - - - - - {data ? toCurrency(data.free) : '-'} - - - {delegationInPHA && data ? ( - - {toCurrency(delegationInPHA)} - - ({toCurrency(data.staked)} vPHA) - - - ) : data ? ( - toCurrency(data.staked) - ) : ( - '-' - )} - - - {rewards ? toCurrency(rewards) : '-'} - - - - - {chain === 'phala' && ( - - - Notice: Staked PHA will be claimed to{' '} - - Phala L2 - - - - )} - - - - - - {polkadotAccount?.name ?? `${chainLabel} account`} - - { - navigator.clipboard.writeText(address) - enqueueSnackbar('Copied to clipboard') - }} - > - - {trimAddress(address, 6, 6)} - - - - - - - - {polkadotAccount != null && ( - <> - - - - {claimed ? ( - - - - Claimed Successfully - - {chain === 'phala' ? ( - - View your assets on{' '} - - Ethereum - - {' and '} - - Phala L2 - - - ) : ( - - Staked PHA and rewards are available on the staking page - - )} - - Tx:{' '} - {logData ? ( - - {logData.trimmedHash} - - ) : ( - - )} - - {chain === 'phala' && logData?.l1Receiver && ( - - Ethereum Receiver:{' '} - - {trimAddress(logData.l1Receiver, 6, 6)} - - - )} - {logData?.receiver && ( - - {chain === 'phala' ? 'Phala L2 Receiver' : 'Receiver'}:{' '} - - {trimAddress(logData.receiver, 6, 6)} - - - )} - {logData?.timestamp && ( - - Time:{' '} - {new Date( - Number.parseInt(logData.timestamp) * 1000, - ).toLocaleString()} - - )} - {chain === 'khala' && ( - - )} - - ) : ( - <> - - - Ethereum wallet - - - - - - - - - - )} - - - )} - - ) -} - -export default ClaimAssets diff --git a/apps/app/components/Layout.tsx b/apps/app/components/Layout.tsx deleted file mode 100644 index 04266d87..00000000 --- a/apps/app/components/Layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {useNotice} from '@/hooks/useNotice' -import {Container} from '@mui/material' -import {useConnectPolkadotWallet} from '@phala/lib' -import type {FC, ReactNode} from 'react' -import ScrollTop from './ScrollTop' -import TopBar from './TopBar' -import WalletDialog from './WalletDialog' - -const Layout: FC<{children: ReactNode}> = ({children}) => { - useConnectPolkadotWallet('Phala App', 30) - useNotice() - - return ( - <> - - - {children} - - - - - ) -} - -export default Layout diff --git a/apps/app/components/PageHeader.tsx b/apps/app/components/PageHeader.tsx deleted file mode 100644 index 44a0525a..00000000 --- a/apps/app/components/PageHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import {Stack, Typography} from '@mui/material' -import type {FC, ReactNode} from 'react' -import Title from './Title' - -const PageHeader: FC<{ - title: string - pageTitle?: ReactNode - children?: ReactNode -}> = ({title, pageTitle, children}) => { - return ( - <> - {title} - - - {pageTitle ?? title} - - - {pageTitle ?? title} - - {children} - - - ) -} - -export default PageHeader diff --git a/apps/app/components/Staking/index.tsx b/apps/app/components/Staking/index.tsx deleted file mode 100644 index 7261e422..00000000 --- a/apps/app/components/Staking/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - useRewardRate, - useShares, - useSharesToAssets, - useTotalAssets, -} from '@/hooks/staking' -import {useAutoSwitchChain} from '@/hooks/useAutoSwitchChain' -import {Box, Grid2 as Grid, Paper, Stack} from '@mui/material' -import {toCurrency, toPercentage} from '@phala/lib' -import Decimal from 'decimal.js' -import {useMemo} from 'react' -import {formatUnits} from 'viem' -import {useAccount} from 'wagmi' -import Property from '../Property' -import Stake from './Stake' -import Unstake from './Unstake' - -const Staking = () => { - const {address} = useAccount() - const shares = useShares(address) - const assets = useSharesToAssets(shares) - const totalAssets = useTotalAssets() - const rewardRate = useRewardRate() - useAutoSwitchChain() - - const apr = useMemo(() => { - if (rewardRate == null || !totalAssets) { - return null - } - return Decimal.mul(rewardRate.toString(), 365 * 24 * 60 * 60).div( - totalAssets.toString(), - ) - }, [rewardRate, totalAssets]) - - return ( - - - - - - {apr != null ? toPercentage(apr) : '-'} - - - - - - - - {assets != null ? toCurrency(formatUnits(assets, 18)) : '-'} - - - - - - - - {totalAssets != null - ? toCurrency(formatUnits(totalAssets, 18)) - : '-'} - - {' '} - - - - - - - - - - - - - ) -} - -export default Staking diff --git a/apps/app/components/SwitchChainButton.tsx b/apps/app/components/SwitchChainButton.tsx deleted file mode 100644 index 595cb191..00000000 --- a/apps/app/components/SwitchChainButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {ethChain} from '@/config' -import {Button} from '@mui/material' -import {useAccount, useSwitchChain} from 'wagmi' - -const SwitchChainButton = ({children}: {children: React.ReactNode}) => { - const {isConnected, chainId} = useAccount() - const {switchChain, isPending} = useSwitchChain() - if (!isConnected || chainId === ethChain.id) { - return children - } - return ( - - ) -} - -export default SwitchChainButton diff --git a/apps/app/components/Title.tsx b/apps/app/components/Title.tsx deleted file mode 100644 index 847910f1..00000000 --- a/apps/app/components/Title.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Head from 'next/head' -import type {FC} from 'react' - -const Title: FC<{children: string}> = ({children}) => { - return ( - - {`${children} | Phala App`} - - ) -} - -export default Title diff --git a/apps/app/components/TopBar/index.tsx b/apps/app/components/TopBar/index.tsx deleted file mode 100644 index 3aa96ff8..00000000 --- a/apps/app/components/TopBar/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import phalaLogo from '@/assets/phala_logo.svg' -import {faDiscord, faXTwitter} from '@fortawesome/free-brands-svg-icons' -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' -import Language from '@mui/icons-material/Language' -import { - AppBar, - Button, - Chip, - IconButton, - NoSsr, - Stack, - Toolbar, - useTheme, -} from '@mui/material' -import Image from 'next/image' -import NextLink from 'next/link' -import type {FC} from 'react' - -const TopBar: FC = () => { - const theme = useTheme() - - const background = '#1f222e' - - return ( - <> - - - - - Phala Network - - - - - - - - - - - {}} - /> - - - - - - - - - - - - - - - - - ) -} - -export default TopBar diff --git a/apps/app/components/Web3Provider.tsx b/apps/app/components/Web3Provider.tsx deleted file mode 100644 index b1d8aa99..00000000 --- a/apps/app/components/Web3Provider.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import {ethChain} from '@/config' -import { - RainbowKitProvider, - darkTheme, - getDefaultConfig, -} from '@rainbow-me/rainbowkit' -import type {ReactNode} from 'react' -import type {Chain} from 'viem' -import {WagmiProvider} from 'wagmi' -import '@rainbow-me/rainbowkit/styles.css' - -const chains: [Chain, ...Chain[]] = [ethChain] - -const config = getDefaultConfig({ - chains, - projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID as string, - appName: 'Phala App', - appUrl: 'https://app.phala.network', - appIcon: 'https://app.phala.network/apple-touch-icon.png', - ssr: true, -}) - -export const Web3Provider = ({ - children, -}: { - children: ReactNode -}) => { - return ( - - - {children} - - - ) -} diff --git a/apps/app/components/app-kit-button.tsx b/apps/app/components/app-kit-button.tsx new file mode 100644 index 00000000..cfba1cbe --- /dev/null +++ b/apps/app/components/app-kit-button.tsx @@ -0,0 +1,29 @@ +'use client' + +import {Button} from '@mui/material' +import {useAppKit, useAppKitAccount} from '@reown/appkit/react' +import {useEffect, useState} from 'react' + +export default function AppKitButton() { + const [mounted, setMounted] = useState(false) + const {isConnected} = useAppKitAccount() + const {open} = useAppKit() + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return null + } + + if (!isConnected) { + return ( + + ) + } + + return +} diff --git a/apps/app/components/asset-card.tsx b/apps/app/components/asset-card.tsx new file mode 100644 index 00000000..51694292 --- /dev/null +++ b/apps/app/components/asset-card.tsx @@ -0,0 +1,207 @@ +'use client' + +import {ArrowForward, OpenInNew} from '@mui/icons-material' +import { + Box, + Button, + Chip, + Paper, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import {toCurrency, trimAddress} from '@phala/lib' +import Image, {type StaticImageData} from 'next/image' +import NextLink from 'next/link' +import type {FC, ReactNode} from 'react' +import {formatUnits} from 'viem' + +import {explorerUrl} from '@/config' + +export interface AssetAction { + label: string + href: string + external?: boolean + variant?: 'text' | 'outlined' | 'contained' + disabled?: boolean +} + +interface AssetCardProps { + icon: StaticImageData + name: string + symbol: string + balance: bigint | null | undefined + contractAddress?: string + contractExplorerUrl?: string + chainLabel?: string + subValue?: ReactNode + actions?: AssetAction[] + highlight?: boolean +} + +const AssetCard: FC = ({ + icon, + name, + symbol, + balance, + contractAddress, + contractExplorerUrl, + chainLabel, + subValue, + actions = [], + highlight = false, +}) => { + const tokenExplorerUrl = contractExplorerUrl + ? `${contractExplorerUrl}/token/${contractAddress}` + : `${explorerUrl}/address/${contractAddress}` + return ( + ({ + p: 2.5, + height: '100%', + display: 'flex', + flexDirection: 'column', + background: highlight + ? `linear-gradient(135deg, ${theme.palette.primary.main}10 0%, transparent 50%)` + : 'transparent', + borderColor: highlight ? 'primary.main' : 'divider', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: 'primary.main', + background: highlight + ? `linear-gradient(135deg, ${theme.palette.primary.main}15 0%, transparent 50%)` + : `${theme.palette.primary.main}08`, + }, + })} + > + + + + + {symbol} + + + + {name} + + + + {symbol} + + {chainLabel && ( + + )} + + + + + + {contractAddress && ( + + {trimAddress(contractAddress)} + + + } + sx={{ + height: 22, + fontSize: '0.7rem', + cursor: 'pointer', + '&:hover': {borderColor: 'primary.main'}, + }} + /> + )} + + + + + {balance === undefined ? ( + + ) : ( + + highlight + ? `linear-gradient(90deg, ${theme.palette.primary.main}, ${theme.palette.primary.light})` + : 'inherit', + backgroundClip: highlight ? 'text' : 'inherit', + WebkitBackgroundClip: highlight ? 'text' : 'inherit', + WebkitTextFillColor: highlight ? 'transparent' : 'inherit', + }} + > + {balance != null ? toCurrency(formatUnits(balance, 18)) : '-'} + + )} + + {subValue && ( + + {subValue} + + )} + + + + {actions.length > 0 && ( + + {actions.map((action) => ( + + ))} + + )} + + + ) +} + +export default AssetCard diff --git a/apps/app/components/claim-assets.tsx b/apps/app/components/claim-assets.tsx new file mode 100644 index 00000000..618c9cbc --- /dev/null +++ b/apps/app/components/claim-assets.tsx @@ -0,0 +1,704 @@ +'use client' + +import {CheckCircleOutline, ContentCopy} from '@mui/icons-material' +import { + Box, + Button, + Divider, + Link, + Paper, + Skeleton, + Stack, + Step, + StepLabel, + Stepper, + TextField, + Typography, +} from '@mui/material' +import {toCurrency, trimAddress, validateAddress} from '@phala/lib' +import {polkadotAccountAtom} from '@phala/store' +import phaIcon from '@phala/ui/icons/asset/pha.png' +import vphaIcon from '@phala/ui/icons/asset/vpha.png' +import {decodeAddress} from '@polkadot/keyring' +import Identicon from '@polkadot/react-identicon' +import type {Signer} from '@polkadot/types/types' +import {stringToHex, u8aToHex} from '@polkadot/util' +import {useAppKitAccount} from '@reown/appkit/react' +import Decimal from 'decimal.js' +import {useAtom, useSetAtom} from 'jotai' +import Image from 'next/image' +import {useSnackbar} from 'notistack' +import {useEffect, useMemo, useState} from 'react' +import type {Hex} from 'viem' +import {useWaitForTransactionReceipt, useWriteContract} from 'wagmi' + +import khalaClaimerAbi from '@/assets/khala_claimer_abi' +import phalaClaimerAbi from '@/assets/phala_claimer_abi' +import AppKitButton from '@/components/app-kit-button' +import Property from '@/components/property' +import SwitchChainButton from '@/components/switch-chain-button' +import { + explorerUrl, + KHALA_CLAIMER_CONTRACT_ADDRESS, + PHALA_CLAIMER_CONTRACT_ADDRESS, +} from '@/config' +import { + type ChainType, + khalaAssetsApi, + phalaAssetsApi, + useAssetsQuery, + useClaimStatus, +} from '@/hooks/khala-assets' +import {useSharePrice} from '@/hooks/staking' +import {useAutoSwitchChain} from '@/hooks/use-auto-switch-chain' +import {toAddress} from '@/lib/wagmi' +import {walletDialogOpenAtom} from '@/store/ui' + +const Steps = () => { + return ( + + + Sign with account + + + Connect Ethereum wallet + + + Claim PHA on Ethereum + + + ) +} + +export const CheckAssets = ({ + onCheck, + chain, +}: { + onCheck: (address: string) => void + chain: ChainType +}) => { + const {enqueueSnackbar} = useSnackbar() + const [checkAddressInput, setCheckAddressInput] = useState('') + const chainLabel = chain.charAt(0).toUpperCase() + chain.slice(1) + + const handleCheck = (e: React.FormEvent) => { + e.preventDefault() + if (validateAddress(checkAddressInput)) { + onCheck(checkAddressInput) + } else { + enqueueSnackbar(`Invalid ${chainLabel} address`, {variant: 'error'}) + } + } + return ( + <> + + { + setCheckAddressInput(e.target.value) + }} + /> + + + + ) +} + +// Phala staking rewards exchange rate (vPHA to PHA) +// This rate is fixed at the snapshot when Phala chain stopped +const PHALA_SHARE_PRICE = new Decimal('42148385.793480375048').div( + '35407137.584585511432035582', +) + +const ClaimAssets = ({chain}: {chain: ChainType}) => { + const {enqueueSnackbar} = useSnackbar() + const setWalletDialogOpen = useSetAtom(walletDialogOpenAtom) + const [isSigning, setIsSigning] = useState(false) + const [checkAddress, setCheckAddress] = useState( + undefined, + ) + useAutoSwitchChain() + const sharePrice = useSharePrice() + const {address: rawEthAddress} = useAppKitAccount() + const ethAddress = toAddress(rawEthAddress) + const [polkadotAccount] = useAtom(polkadotAccountAtom) + const address = checkAddress ?? polkadotAccount?.address + const {data} = useAssetsQuery(address, chain) + const h160Address = useMemo(() => { + if (address == null) { + return undefined + } + const publicKey = decodeAddress(address) + const h160 = u8aToHex(publicKey).slice(0, 42) as Hex + return h160 + }, [address]) + const {claimed, log, refetch} = useClaimStatus(h160Address, chain) + const logData = useMemo(() => { + if (log == null) { + return undefined + } + + const txHash = log.transactionHash_ + const freeAmount = new Decimal(log.free).div(1e18) + // staked is in vPHA units + const stakedVPHA = new Decimal(log.staked).div(1e18) + + // Convert vPHA to PHA based on chain + let stakedPHA: Decimal + if (chain === 'phala') { + // For Phala, use fixed snapshot rate + stakedPHA = stakedVPHA.mul(PHALA_SHARE_PRICE) + } else { + // For Khala, vPHA price is 1:1 (no conversion needed) + stakedPHA = stakedVPHA + } + + const totalAmount = freeAmount.add(stakedPHA) + + return { + url: `${explorerUrl}/tx/${txHash}`, + hash: txHash, + trimmedHash: trimAddress(txHash), + receiver: log.receiver, + l1Receiver: 'l1Receiver' in log ? log.l1Receiver : undefined, + timestamp: log.timestamp_, + free: freeAmount, + stakedVPHA, // Original vPHA amount + stakedPHA, // Converted PHA amount + total: totalAmount, + } + }, [log, chain, sharePrice]) + + const {data: hash, writeContract, isPending, reset} = useWriteContract() + const claimResult = useWaitForTransactionReceipt({hash}) + + const isLoading = isPending || claimResult.isLoading || isSigning + + const isClaimable = useMemo(() => { + if (data == null) { + return false + } + return new Decimal(data.free).add(data.staked).gt(0) + }, [data]) + + useEffect(() => { + if (claimResult.data?.status === 'success') { + enqueueSnackbar('Claimed successfully', {variant: 'success'}) + reset() + refetch() + } + }, [claimResult.data?.status, enqueueSnackbar, reset, refetch]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!ethAddress) { + return + } + const receiver = ethAddress + const {signRaw} = polkadotAccount?.signer as Signer + if (signRaw == null || polkadotAccount == null || h160Address == null) { + return + } + let polkadotSignature: Hex + try { + setIsSigning(true) + const {signature} = await signRaw({ + address: polkadotAccount.address, + data: stringToHex( + `${chain.charAt(0).toUpperCase() + chain.slice(1)} Asset Receiver: ${receiver}`, + ), + type: 'bytes', + }) + polkadotSignature = signature + } catch (e) { + setIsSigning(false) + return + } + + try { + const api = chain === 'khala' ? khalaAssetsApi : phalaAssetsApi + const {h160, free, staked, signature} = await api + .url('/claim') + .post({ + address: polkadotAccount.address, + signature: polkadotSignature, + receiver, + }) + .json<{h160: Hex; free: string; staked: string; signature: Hex}>() + + const contractAddress = + chain === 'khala' + ? KHALA_CLAIMER_CONTRACT_ADDRESS + : PHALA_CLAIMER_CONTRACT_ADDRESS + + const contractAbi = chain === 'khala' ? khalaClaimerAbi : phalaClaimerAbi + + writeContract({ + abi: contractAbi, + address: contractAddress, + functionName: 'claim', + args: [h160, BigInt(free), BigInt(staked), receiver, signature], + }) + } catch (e) { + return enqueueSnackbar('Failed to claim', {variant: 'error'}) + } finally { + setIsSigning(false) + } + } + + const onSwitchAccount = () => { + if (polkadotAccount == null) { + setCheckAddress(undefined) + } else { + setWalletDialogOpen(true) + } + } + + // Convert vPHA delegation to real PHA for Phala chain + const delegationInPHA = useMemo(() => { + if (chain !== 'phala' || data?.staked == null) { + return undefined + } + return new Decimal(data.staked).mul(PHALA_SHARE_PRICE) + }, [chain, data?.staked]) + + const rewards = useMemo(() => { + if (data?.staked == null) { + return + } + if (chain === 'phala') { + // For Phala, use current share price minus snapshot share price + if (sharePrice == null) { + return + } + const currentRate = new Decimal(sharePrice.toString()).div(1e18) + const reward = currentRate.minus(PHALA_SHARE_PRICE).mul(data.staked) + // Ensure rewards are non-negative (handle rounding errors) + return Decimal.max(0, reward) + } + // For Khala, vPHA price is 1:1, use current share price minus 1 for rewards + if (sharePrice == null) { + return + } + const reward = new Decimal(sharePrice.toString()) + .div(1e18) + .minus(1) + .mul(data.staked) + // Ensure rewards are non-negative (handle rounding errors) + return Decimal.max(0, reward) + }, [sharePrice, data?.staked, chain]) + + const total = useMemo(() => { + if (data == null || rewards == null) { + return + } + // For Phala, use converted delegation amount; for Khala, use staked amount as is + const stakedAmount = delegationInPHA ?? data.staked + return Decimal.add(data.free, stakedAmount).add(rewards) + }, [data, rewards, delegationInPHA]) + + const chainLabel = chain.charAt(0).toUpperCase() + chain.slice(1) + + if (address == null) { + return ( + + + Connect wallet to claim + + + + + + + + Check your {chainLabel} account + + + + + ) + } + + return ( + <> + + {total == null ? '-' : toCurrency(total)} + + + + + {data ? toCurrency(data.free) : '-'} + + + {delegationInPHA && data ? ( + + {toCurrency(delegationInPHA)} + + ({toCurrency(data.staked)} vPHA on Phala L2) + + + ) : data ? ( + toCurrency(data.staked) + ) : ( + '-' + )} + + + {rewards ? toCurrency(rewards) : '-'} + + + + + + + + + {polkadotAccount?.name ?? `${chainLabel} account`} + + { + navigator.clipboard.writeText(address) + enqueueSnackbar('Copied to clipboard') + }} + > + + {trimAddress(address)} + + + + + + + + {polkadotAccount != null && ( + <> + + + + {claimed ? ( + + {/* Header */} + + + + Claimed + + {logData && ( + + + + {logData.trimmedHash} + + + {logData.timestamp && ( + + {new Date( + Number.parseInt(logData.timestamp) * 1000, + ).toLocaleString()} + + )} + + )} + + + {/* Claim Details */} + {logData ? ( + + {chain === 'phala' ? ( + <> + {/* Free PHA to Ethereum */} + {logData.free.gt(0) && ( + + + PHA + + {toCurrency(logData.free)} PHA + + + → + + + + {trimAddress(logData.l1Receiver ?? '')} + + + + + Ethereum + + + )} + + {/* Staked vPHA to Phala L2 */} + {logData.stakedVPHA.gt(0) && ( + + + vPHA + + {toCurrency(logData.stakedVPHA)} vPHA + + + → + + + + {trimAddress(logData.receiver)} + + + + + Phala L2 + + + )} + + ) : ( + <> + {/* Khala: Free PHA to Ethereum */} + {logData.free.gt(0) && ( + + + PHA + + {toCurrency(logData.free)} PHA + + + → + + + + {trimAddress(logData.receiver)} + + + + + Ethereum + + + )} + + {/* Khala: Staked vPHA to Ethereum */} + {logData.stakedVPHA.gt(0) && ( + + + vPHA + + {toCurrency(logData.stakedVPHA)} vPHA + + + → + + + + {trimAddress(logData.receiver)} + + + + + Ethereum + + + )} + + )} + + ) : ( + + )} + + ) : ( + <> + + + Ethereum wallet + + + + + + + + + + )} + + + )} + + ) +} + +export default ClaimAssets diff --git a/apps/app/components/footer.tsx b/apps/app/components/footer.tsx new file mode 100644 index 00000000..69d22d3b --- /dev/null +++ b/apps/app/components/footer.tsx @@ -0,0 +1,79 @@ +'use client' + +import type {IconProp} from '@fortawesome/fontawesome-svg-core' +import {faDiscord, faXTwitter} from '@fortawesome/free-brands-svg-icons' +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' +import Language from '@mui/icons-material/Language' +import { + Box, + Container, + IconButton, + Stack, + Typography, + useTheme, +} from '@mui/material' +import type {FC} from 'react' + +const Footer: FC = () => { + const theme = useTheme() + + return ( + + + + + © {new Date().getFullYear()} Phala Network + + + + + + + + + + + + + + + + + ) +} + +export default Footer diff --git a/apps/app/components/layout.tsx b/apps/app/components/layout.tsx new file mode 100644 index 00000000..322ad955 --- /dev/null +++ b/apps/app/components/layout.tsx @@ -0,0 +1,26 @@ +'use client' + +import {Box, Container} from '@mui/material' +import type {FC, ReactNode} from 'react' + +import {useNotice} from '@/hooks/use-notice' +import Footer from './footer' +import ScrollTop from './scroll-top' +import TopBar from './top-bar' + +const Layout: FC<{children: ReactNode}> = ({children}) => { + useNotice() + + return ( + + + + {children} + +