diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27f68d0c..9a5e9570 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,10 +21,34 @@ jobs: run: | nix develop . -c yarn install - - name: lint + - name: Lint hathor-rpc-handler run: | nix develop . -c yarn workspace @hathor/hathor-rpc-handler run lint - - name: tests + - name: Test hathor-rpc-handler run: | nix develop . -c yarn workspace @hathor/hathor-rpc-handler run test + + - name: Lint web-wallet + run: | + nix develop . -c yarn workspace @hathor/web-wallet run lint + + - name: Test web-wallet + run: | + nix develop . -c yarn workspace @hathor/web-wallet run test --run + + - name: Install Playwright browsers + run: | + nix develop . -c yarn workspace @hathor/web-wallet exec playwright install chromium --with-deps + + - name: E2E test web-wallet + run: | + nix develop . -c yarn workspace @hathor/web-wallet exec playwright test --project=chromium + + - name: Lint snap + run: | + nix develop . -c yarn workspace @hathor/snap run lint + + - name: Test snap + run: | + nix develop . -c yarn workspace @hathor/snap run test diff --git a/.gitignore b/.gitignore index 34076192..963e6c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage/ public/ *.swp *.swo +.claude diff --git a/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch b/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch new file mode 100644 index 00000000..286711e6 --- /dev/null +++ b/.yarn/patches/bitcore-lib-npm-8.25.10-2b2055eaf2.patch @@ -0,0 +1,12 @@ +diff --git a/index.js b/index.js +index 4cbe6bf2ac69202558e0cfb8457fec21c2d48017..98cad3a403bacc0ebd2d1223d3c17adc23a53bc7 100644 +--- a/index.js ++++ b/index.js +@@ -5,6 +5,7 @@ var bitcore = module.exports; + // module information + bitcore.version = 'v' + require('./package.json').version; + bitcore.versionGuard = function(version) { ++ return; + if (version !== undefined) { + var message = 'More than one instance of bitcore-lib found. ' + + 'Please make sure to require bitcore-lib and check that submodules do' + diff --git a/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch b/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch index e6795490..87bd5457 100644 --- a/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch +++ b/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch @@ -22,4 +22,4 @@ index 6bea796b8a174a1ecb24d3c745fc32edf83bf4c2..7002d96c2e5571ca8097f286190f937a + r = Q.getX().umod(N); s = k.invm(N).mul(e.add(d.mul(r))).umod(N); } while (r.cmp(BN.Zero) <= 0 || s.cmp(BN.Zero) <= 0); - + diff --git a/Dockerfile.web-wallet b/Dockerfile.web-wallet new file mode 100644 index 00000000..93709dfa --- /dev/null +++ b/Dockerfile.web-wallet @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy yarn configuration and patches +COPY .yarnrc.yml ./ +COPY .yarn ./.yarn +COPY package.json yarn.lock ./ +COPY packages/web-wallet/package.json ./packages/web-wallet/ +COPY packages/snap-utils/package.json ./packages/snap-utils/ +COPY packages/hathor-rpc-handler/package.json ./packages/hathor-rpc-handler/ + +# Install dependencies +RUN corepack enable && yarn install + +# Copy source code +COPY packages/web-wallet ./packages/web-wallet +COPY packages/snap-utils ./packages/snap-utils +COPY packages/hathor-rpc-handler ./packages/hathor-rpc-handler + +# Build snap-utils first (web-wallet depends on it) +WORKDIR /app/packages/snap-utils +RUN yarn build + +# Build hathor-rpc-handler (snap-utils depends on it) +WORKDIR /app/packages/hathor-rpc-handler +RUN yarn build + +# Build the web-wallet application +WORKDIR /app/packages/web-wallet +RUN yarn build + +# Production stage +FROM nginx:alpine + +# Copy built files to nginx +COPY --from=builder /app/packages/web-wallet/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY packages/web-wallet/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/package.json b/package.json index 9c8ae570..bed7e69f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "workspaces": [ "packages/hathor-rpc-handler", "packages/snap", - "packages/snap-utils" + "packages/snap-utils", + "packages/web-wallet" ], "resolutions": { "bitcore-lib@npm:8.25.10": "patch:bitcore-lib@npm%3A8.25.47#~/.yarn/patches/bitcore-lib-npm-8.25.47-62066b9183.patch", diff --git a/packages/snap-utils/src/react-hooks/MetamaskContext.tsx b/packages/snap-utils/src/react-hooks/MetamaskContext.tsx index 7e3733f8..41c04b0f 100644 --- a/packages/snap-utils/src/react-hooks/MetamaskContext.tsx +++ b/packages/snap-utils/src/react-hooks/MetamaskContext.tsx @@ -59,4 +59,4 @@ export const MetaMaskProvider = ({ children }: { children: ReactNode }) => { */ export function useMetaMaskContext() { return useContext(MetaMaskContext); -} \ No newline at end of file +} diff --git a/packages/snap-utils/src/react-hooks/useRequest.ts b/packages/snap-utils/src/react-hooks/useRequest.ts index ed085852..25c0027d 100644 --- a/packages/snap-utils/src/react-hooks/useRequest.ts +++ b/packages/snap-utils/src/react-hooks/useRequest.ts @@ -19,10 +19,10 @@ export const useRequest = () => { * @param params.method - The method to call. * @param params.params - The method params. * @returns The result of the request. + * @throws Will throw an error if the request fails. */ const request: Request = async ({ method, params }) => { try { - setError(null); const data = (await provider?.request({ method, @@ -31,9 +31,13 @@ export const useRequest = () => { return data; } catch (requestError: any) { + // Set error in context for UI notifications setError(requestError); - return null; + // Re-throw the error so callers can distinguish between + // null response vs error. This allows proper error handling + // instead of treating all errors as null responses. + throw requestError; } }; diff --git a/packages/snap-utils/src/types.ts b/packages/snap-utils/src/types.ts index 34982d82..c8b2b2ae 100644 --- a/packages/snap-utils/src/types.ts +++ b/packages/snap-utils/src/types.ts @@ -26,7 +26,7 @@ interface EIP6963AnnounceProviderEvent extends CustomEvent<{ rdns: string; }; provider: MetaMaskInpageProvider; -}> {} +}> { } declare global { interface WindowEventMap { diff --git a/packages/snap-utils/tsconfig.json b/packages/snap-utils/tsconfig.json index 768b4e50..5572c9b0 100644 --- a/packages/snap-utils/tsconfig.json +++ b/packages/snap-utils/tsconfig.json @@ -20,4 +20,3 @@ "dist" ] } - diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 7c091022..d44d815c 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -3,7 +3,7 @@ "description": "Hathor Network Snap integration", "proposedName": "Hathor Wallet", "source": { - "shasum": "9j6opOXoJBG0aZv1ZsDnlCpUZ55XZbUVWBABoFvJYIQ=", + "shasum": "Zt62bKmJtkjJKomv2U76V5gdAG447bsG8ytJGS144Ac=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/constants.ts b/packages/snap/src/constants.ts index c6b731c8..7477d908 100644 --- a/packages/snap/src/constants.ts +++ b/packages/snap/src/constants.ts @@ -31,9 +31,9 @@ const NANO_BRAVO_URLS = { } const TESTNET_URLS = { - nodeURL: 'https://node1.testnet.hathor.network/v1a/', - walletServiceURL: 'https://wallet-service.testnet.hathor.network', - txMiningURL: 'https://txmining.testnet.hathor.network/', + nodeURL: 'https://node1.india.testnet.hathor.network/v1a/', + walletServiceURL: 'https://wallet-service.india.testnet.hathor.network', + txMiningURL: 'https://txmining.india.testnet.hathor.network/', network: 'testnet', } @@ -52,6 +52,6 @@ export const NETWORK_MAP = { 'dev-testnet': DEV_TESTNET_URLS, } -export const DEFAULT_NETWORK = 'mainnet'; +export const DEFAULT_NETWORK = 'testnet'; export const DEFAULT_PIN_CODE = '123'; diff --git a/packages/snap/src/dialogs/address.tsx b/packages/snap/src/dialogs/address.tsx index 86edc572..9777c211 100644 --- a/packages/snap/src/dialogs/address.tsx +++ b/packages/snap/src/dialogs/address.tsx @@ -59,4 +59,4 @@ export const addressPage = async (data, params, origin) => ( ), }, }) -) \ No newline at end of file +) diff --git a/packages/snap/src/dialogs/createNano.tsx b/packages/snap/src/dialogs/createNano.tsx index eb0bbb97..79dce066 100644 --- a/packages/snap/src/dialogs/createNano.tsx +++ b/packages/snap/src/dialogs/createNano.tsx @@ -6,7 +6,7 @@ */ import { REQUEST_METHODS, DIALOG_TYPES } from '../constants'; -import { Bold, Box, Card, Container, Copyable, Divider, Heading, Icon, Section, Text, Tooltip } from '@metamask/snaps-sdk/jsx'; +import { Bold, Box, Card, Container, Copyable, Heading, Icon, Section, Text, Tooltip } from '@metamask/snaps-sdk/jsx'; import { constants as libConstants, bigIntUtils, dateUtils, NanoContractActionType, numberUtils } from '@hathor/wallet-lib'; const renderOptionalContractDetail = (param, title) => { diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index 8fe3f861..3a1d34bd 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -59,7 +59,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ }) => { // Almost all RPC requests need the network, so I add it here const networkData = await getNetworkData(); + request.params = { ...request.params, network: networkData.network }; + // Use read-only wallet for requests that don't require signing const isReadOnly = READ_ONLY_METHODS.has(request.method as RpcMethods); diff --git a/packages/snap/src/utils/network.ts b/packages/snap/src/utils/network.ts index cf89a61a..f1f7e59e 100644 --- a/packages/snap/src/utils/network.ts +++ b/packages/snap/src/utils/network.ts @@ -25,6 +25,7 @@ export const getNetworkData = async () => { /* * Set network, persist data in storage and update wallet lib config + * Also initializes the wallet on the new network's wallet-service */ export const setNetwork = async (network: string) => { const persistedData = await snap.request({ diff --git a/packages/snap/src/utils/wallet.ts b/packages/snap/src/utils/wallet.ts index 3ee64090..349f2d20 100644 --- a/packages/snap/src/utils/wallet.ts +++ b/packages/snap/src/utils/wallet.ts @@ -143,4 +143,4 @@ export const getAndStartHathorWallet = async (): Promise + + + + + Hathor Web Wallet + + + + + + + +
+ + + diff --git a/packages/web-wallet/nginx.conf b/packages/web-wallet/nginx.conf new file mode 100644 index 00000000..371a12b8 --- /dev/null +++ b/packages/web-wallet/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Handle React Router (SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/packages/web-wallet/package.json b/packages/web-wallet/package.json new file mode 100644 index 00000000..878187b3 --- /dev/null +++ b/packages/web-wallet/package.json @@ -0,0 +1,64 @@ +{ + "name": "@hathor/web-wallet", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + }, + "dependencies": { + "@hathor/snap-utils": "workspace:^", + "@hathor/wallet-lib": "2.9.0", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.65.0", + "react-qr-code": "^2.0.18", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@playwright/test": "^1.54.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/events": "^3", + "@types/jsdom": "^27", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "@vitest/ui": "^4.0.1", + "assert": "^2.1.0", + "autoprefixer": "^10.4.21", + "buffer": "^6.0.3", + "crypto-browserify": "^3.12.1", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "events": "^3.3.0", + "globals": "^16.3.0", + "jsdom": "^27.0.1", + "postcss": "^8.5.6", + "process": "^0.11.10", + "stream-browserify": "^3.0.0", + "tailwindcss": "3.4.17", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2", + "vitest": "^4.0.1" + } +} diff --git a/packages/web-wallet/playwright-report/index.html b/packages/web-wallet/playwright-report/index.html new file mode 100644 index 00000000..50c30f56 --- /dev/null +++ b/packages/web-wallet/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/packages/web-wallet/playwright.config.ts b/packages/web-wallet/playwright.config.ts new file mode 100644 index 00000000..703a9ec0 --- /dev/null +++ b/packages/web-wallet/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Add extension support when available + // args: ['--load-extension=./extensions/metamask-flask'], + // headless: false, + }, + }, + // Firefox and Webkit disabled - browsers not installed + // Uncomment and run `npx playwright install` to enable + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + webServer: { + command: 'yarn dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/packages/web-wallet/postcss.config.js b/packages/web-wallet/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/packages/web-wallet/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/web-wallet/public/apple-touch-icon.png b/packages/web-wallet/public/apple-touch-icon.png new file mode 100644 index 00000000..9c151173 Binary files /dev/null and b/packages/web-wallet/public/apple-touch-icon.png differ diff --git a/packages/web-wallet/public/favicon-32x32.png b/packages/web-wallet/public/favicon-32x32.png new file mode 100644 index 00000000..b267b8a0 Binary files /dev/null and b/packages/web-wallet/public/favicon-32x32.png differ diff --git a/packages/web-wallet/public/favicon.svg b/packages/web-wallet/public/favicon.svg new file mode 100644 index 00000000..29a1b002 --- /dev/null +++ b/packages/web-wallet/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/packages/web-wallet/src/App.tsx b/packages/web-wallet/src/App.tsx new file mode 100644 index 00000000..9667fe00 --- /dev/null +++ b/packages/web-wallet/src/App.tsx @@ -0,0 +1,22 @@ +import { MetaMaskProvider } from '@hathor/snap-utils' +import { WalletProvider } from './contexts/WalletContext' +import WalletHome from './components/WalletHome' +import { ErrorBoundary } from './components/ErrorBoundary' + +function App() { + return ( + + + + + + + + + + + + ) +} + +export default App diff --git a/packages/web-wallet/src/assets/htr_logo.svg b/packages/web-wallet/src/assets/htr_logo.svg new file mode 100644 index 00000000..2887fdec --- /dev/null +++ b/packages/web-wallet/src/assets/htr_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/web-wallet/src/assets/htr_logo_black.svg b/packages/web-wallet/src/assets/htr_logo_black.svg new file mode 100644 index 00000000..2b23ede0 --- /dev/null +++ b/packages/web-wallet/src/assets/htr_logo_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web-wallet/src/assets/htr_logo_black_outline.svg b/packages/web-wallet/src/assets/htr_logo_black_outline.svg new file mode 100644 index 00000000..c97b7714 --- /dev/null +++ b/packages/web-wallet/src/assets/htr_logo_black_outline.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/web-wallet/src/assets/htr_logo_white.svg b/packages/web-wallet/src/assets/htr_logo_white.svg new file mode 100644 index 00000000..7cb449d1 --- /dev/null +++ b/packages/web-wallet/src/assets/htr_logo_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web-wallet/src/assets/htr_logo_white_outline.svg b/packages/web-wallet/src/assets/htr_logo_white_outline.svg new file mode 100644 index 00000000..eba8360f --- /dev/null +++ b/packages/web-wallet/src/assets/htr_logo_white_outline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/web-wallet/src/components/ChangeNetworkDialog.tsx b/packages/web-wallet/src/components/ChangeNetworkDialog.tsx new file mode 100644 index 00000000..889f46db --- /dev/null +++ b/packages/web-wallet/src/components/ChangeNetworkDialog.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { X, AlertCircle, ChevronDown } from 'lucide-react'; +import { useWallet } from '../contexts/WalletContext'; +import { NETWORKS, WALLET_SERVICE_URLS } from '../constants'; + +interface ChangeNetworkDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const NETWORK_OPTIONS = [ + { + id: NETWORKS.MAINNET, + name: 'Mainnet', + url: WALLET_SERVICE_URLS.MAINNET, + }, + { + id: NETWORKS.TESTNET, + name: 'Testnet', + url: WALLET_SERVICE_URLS.TESTNET, + }, +]; + +const ChangeNetworkDialog: React.FC = ({ isOpen, onClose }) => { + const { network, changeNetwork } = useWallet(); + const [selectedNetwork, setSelectedNetwork] = useState(network); + + const handleChangeNetwork = async () => { + if (selectedNetwork === network) { + onClose(); + return; + } + + await changeNetwork(selectedNetwork); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Change network

+ +
+ + {/* Content */} +
+
+

+ This is where you can switch your Hathor Wallet network. +

+

+ Make sure you understand the risks before continuing. +

+
+ + {/* Warning */} +
+ +

+ Changing the network can expose your wallet to risks. Only proceed if you know what + you're doing; never switch networks based on third-party suggestions, as this may lead + to fraud. +

+
+ + {/* Network Selection */} +
+ +
+ + +
+
+ + {/* Change Network Button */} +
+ +
+
+
+
+ ); +}; + +export default ChangeNetworkDialog; diff --git a/packages/web-wallet/src/components/ErrorBoundary.tsx b/packages/web-wallet/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..17198d01 --- /dev/null +++ b/packages/web-wallet/src/components/ErrorBoundary.tsx @@ -0,0 +1,58 @@ +import React, { Component, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Please refresh the page to continue. +

+
+ + {this.state.error?.message || 'Unknown error'} + +
+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/packages/web-wallet/src/components/ErrorNotification.tsx b/packages/web-wallet/src/components/ErrorNotification.tsx new file mode 100644 index 00000000..2debfd66 --- /dev/null +++ b/packages/web-wallet/src/components/ErrorNotification.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { AlertCircle, X } from 'lucide-react'; + +interface ErrorNotificationProps { + error: Error | null; + onDismiss: () => void; +} + +/** + * Error notification component that displays errors in a toast-like notification. + * Auto-dismisses after 10 seconds but can be manually dismissed. + */ +const ErrorNotification: React.FC = ({ error, onDismiss }) => { + if (!error) return null; + + return ( +
+
+
+ +
+

Error

+

{error.message}

+
+ +
+
+
+ ); +}; + +export default ErrorNotification; diff --git a/packages/web-wallet/src/components/Header.tsx b/packages/web-wallet/src/components/Header.tsx new file mode 100644 index 00000000..2ae2e116 --- /dev/null +++ b/packages/web-wallet/src/components/Header.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { Copy, Globe } from 'lucide-react'; +import { useWallet } from '../contexts/WalletContext'; +import { truncateAddress } from '../utils/hathor'; +import ChangeNetworkDialog from './ChangeNetworkDialog'; +import htrLogo from '../htr_logo.svg'; +import { NETWORKS } from '../constants'; + +const Header: React.FC = () => { + const { address, network } = useWallet(); + const [isNetworkDialogOpen, setIsNetworkDialogOpen] = useState(false); + + const handleCopyAddress = () => { + if (address) { + navigator.clipboard.writeText(address); + } + }; + + const getNetworkDisplayName = (networkId: string) => { + switch (networkId) { + case NETWORKS.MAINNET: + return 'Mainnet'; + case NETWORKS.TESTNET: + return 'Testnet'; + default: + return networkId; + } + }; + + return ( + <> +
+
+ {/* Left: Logo + Badge */} +
+
+ Hathor +
+ WEB WALLET +
+
+
+ + {/* Right: Address + Network + Menu */} +
+ {/* Wallet Address */} + + + {/* Network Button */} + +
+
+
+ + {/* Change Network Dialog */} + setIsNetworkDialogOpen(false)} + /> + + ); +}; + +export default Header; diff --git a/packages/web-wallet/src/components/HistoryDialog.tsx b/packages/web-wallet/src/components/HistoryDialog.tsx new file mode 100644 index 00000000..bdf25528 --- /dev/null +++ b/packages/web-wallet/src/components/HistoryDialog.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react' +import { ArrowUpRight, ArrowDownLeft, ExternalLink, Loader2, ArrowLeft, Clock } from 'lucide-react' +import { useWallet } from '../contexts/WalletContext' +import type { TransactionHistoryItem } from '../services/ReadOnlyWalletService' +import { formatHTRAmount, toBigInt } from '../utils/hathor' +import { HATHOR_EXPLORER_URLS, NETWORKS, TOKEN_IDS } from '../constants' +import Header from './Header' + +interface HistoryDialogProps { + isOpen: boolean + onClose: () => void +} + +interface ProcessedTransaction { + id: string + type: 'sent' | 'received' + amount: bigint + timestamp: string + txHash: string + status: 'confirmed' | 'pending' +} + +const HistoryDialog: React.FC = ({ isOpen, onClose }) => { + const [transactions, setTransactions] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [currentCount, setCurrentCount] = useState(0) + const PAGE_SIZE = 10 + const { address, network, getTransactionHistory, newTransaction, setHistoryDialogState, clearNewTransaction } = useWallet() + + useEffect(() => { + if (isOpen && address) { + // Notify context that dialog is open on page 0 (page 1 in UI) + setHistoryDialogState(true, 0) + // Reset pagination when dialog opens + setTransactions([]) + setCurrentCount(0) + setHasMore(true) + loadTransactionHistory(0) + } else if (!isOpen) { + // Notify context that dialog is closed + setHistoryDialogState(false, 0) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, address]) + + // Handle incoming new transactions from WebSocket + useEffect(() => { + if (!newTransaction || !isOpen) return; + + // Type guard to check if this is a history transaction + const transaction = newTransaction as Record; + + // Only process if we have transaction data with tx_id (for history list) + if (transaction.tx_id) { + const balanceValue = toBigInt(transaction.balance as number | bigint); + const type = balanceValue >= 0n ? 'received' : 'sent' + const amount = balanceValue >= 0n ? balanceValue : -balanceValue + + const processedTx: ProcessedTransaction = { + id: transaction.tx_id as string, + type, + amount, + timestamp: new Date((transaction.timestamp as number) * 1000).toISOString(), + txHash: transaction.tx_id as string, + status: !(transaction.is_voided as boolean) ? 'confirmed' : 'pending' + }; + + // Use functional update to avoid race conditions with duplicate check + setTransactions(prev => { + const isDuplicate = prev.some(tx => tx.id === transaction.tx_id); + if (isDuplicate) return prev; + return [processedTx, ...prev].slice(0, PAGE_SIZE); + }); + + setCurrentCount(prev => prev + 1); + + // Clear the transaction from context + clearNewTransaction(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newTransaction, isOpen]); + + const loadTransactionHistory = async (skip: number = 0) => { + if (!address) { + return; + } + + const isInitialLoad = skip === 0; + if (isInitialLoad) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + + // Calculate page number (0-indexed) + const pageNum = Math.floor(skip / PAGE_SIZE); + + try { + const history = await getTransactionHistory(PAGE_SIZE, skip, TOKEN_IDS.HTR) + + if (!history || history.length === 0) { + setHasMore(false); + if (isInitialLoad) { + setTransactions([]); + } + return; + } + + if (history.length < PAGE_SIZE) { + setHasMore(false); + } + + const processed: ProcessedTransaction[] = history.map((tx: TransactionHistoryItem) => { + const balanceValue = toBigInt(tx.balance); + const type = balanceValue >= 0n ? 'received' : 'sent' + const amount = balanceValue >= 0n ? balanceValue : -balanceValue + + return { + id: tx.tx_id, + type, + amount, + timestamp: new Date(tx.timestamp * 1000).toISOString(), + txHash: tx.tx_id, + status: !tx.is_voided ? 'confirmed' : 'pending' + } + }) + if (isInitialLoad) { + setTransactions(processed); + setCurrentCount(processed.length); + } else { + setTransactions(prev => [...prev, ...processed]); + setCurrentCount(prev => prev + processed.length); + } + + // Notify context of page change + setHistoryDialogState(true, pageNum); + } catch (error) { + console.error('Failed to load transaction history:', error) + } finally { + if (isInitialLoad) { + setIsLoading(false); + } else { + setIsLoadingMore(false); + } + } + } + + const handleLoadMore = () => { + loadTransactionHistory(currentCount); + } + + const formatDate = (timestamp: string) => { + return new Date(timestamp).toLocaleString() + } + + const openExplorer = (txHash: string) => { + const baseUrl = network === NETWORKS.MAINNET + ? HATHOR_EXPLORER_URLS.MAINNET + : HATHOR_EXPLORER_URLS.TESTNET + window.open(`${baseUrl}/transaction/${txHash}`, '_blank') + } + + if (!isOpen) return null + + return ( +
+
+ + {/* Main Content */} +
+ {/* Back Button */} + + + {/* Title */} +

+ Transaction History +

+ + {/* Transactions List */} +
+ {isLoading ? ( +
+ +

Loading transaction history...

+
+ ) : transactions.length > 0 ? ( + <> + {transactions.map((tx) => ( +
+ {/* Left: Icon + Info */} +
+
+ {tx.type === 'received' ? ( + + ) : ( + + )} +
+
+

+ {tx.type} Token +

+

+ {formatDate(tx.timestamp)} +

+
+
+ + {/* Right: Amount + Explorer Link */} +
+

+ {tx.type === 'received' ? '+' : '-'}{formatHTRAmount(tx.amount)} + HTR +

+ +
+
+ ))} + + {/* See More Button */} + {hasMore && ( +
+ +
+ )} + + ) : ( +
+ +

No transactions yet

+

Your transaction history will appear here

+
+ )} +
+
+
+ ) +} + +export default HistoryDialog \ No newline at end of file diff --git a/packages/web-wallet/src/components/ReceiveDialog.tsx b/packages/web-wallet/src/components/ReceiveDialog.tsx new file mode 100644 index 00000000..756e9ccb --- /dev/null +++ b/packages/web-wallet/src/components/ReceiveDialog.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { X, Copy } from 'lucide-react'; +import QRCode from 'react-qr-code'; +import { useWallet } from '../contexts/WalletContext'; +import { QR_CODE_SIZE } from '../constants'; + +interface ReceiveDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const ReceiveDialog: React.FC = ({ isOpen, onClose }) => { + const [copied, setCopied] = useState(false); + const { address } = useWallet(); + + const handleCopy = async () => { + if (!address) return; + + try { + await navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Receive Tokens

+ +
+ + {/* Content */} +
+ {/* Subtitle */} +

+ Send HTR or custom tokens to this address. +

+ + {/* QR Code */} +
+
+ {address ? ( + + ) : ( +
+

No address available

+
+ )} +
+
+ + {/* Wallet Address - Centered without label */} +
+
+

+ {address || 'No address available'} +

+
+
+ + {/* Copy Button - Purple/Violet */} + +
+
+
+ ); +}; + +export default ReceiveDialog; \ No newline at end of file diff --git a/packages/web-wallet/src/components/SendDialog.tsx b/packages/web-wallet/src/components/SendDialog.tsx new file mode 100644 index 00000000..22aecdad --- /dev/null +++ b/packages/web-wallet/src/components/SendDialog.tsx @@ -0,0 +1,301 @@ +import React, { useState } from 'react'; +import { X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useWallet } from '../contexts/WalletContext'; +import { formatHTRAmount, htrToCents, centsToHTR } from '../utils/hathor'; +import { Network } from '@hathor/wallet-lib'; +import Address from '@hathor/wallet-lib/lib/models/address'; +import { TOKEN_IDS, HTR_DECIMAL_MULTIPLIER } from '../constants'; + +interface SendDialogProps { + isOpen: boolean; + onClose: () => void; +} + +// Create a Zod schema factory for form validation +const createSendFormSchema = (availableBalance: bigint, network: string) => + z.object({ + selectedToken: z.string(), + amount: z + .string() + .min(1, 'Amount is required') + .regex(/^\d+(\.\d{1,2})?$/, 'Invalid amount format. Use up to 2 decimal places.') + .refine((val) => { + try { + const amountInCents = htrToCents(val); + return amountInCents > 0n; + } catch { + return false; + } + }, 'Amount must be greater than 0') + .refine((val) => { + try { + const amountInCents = htrToCents(val); + return amountInCents <= availableBalance; + } catch { + return false; + } + }, 'Insufficient balance'), + address: z + .string() + .min(1, 'Address is required') + .superRefine((val, ctx) => { + try { + const networkObj = new Network(network); + const addressObj = new Address(val.trim(), { network: networkObj }); + addressObj.validateAddress(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Invalid address'; + let customMessage = 'Invalid Hathor address format'; + + if (errorMsg.includes('checksum')) { + customMessage = 'Invalid address checksum. Please check the address.'; + } else if (errorMsg.includes('network')) { + customMessage = `Invalid address for ${network} network`; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: customMessage, + }); + } + }), + timelock: z.string().optional(), + dataOutput: z.string().optional(), + }); + +type SendFormData = z.infer>; + +const SendDialog: React.FC = ({ isOpen, onClose }) => { + const [showAdvanced, setShowAdvanced] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [transactionError, setTransactionError] = useState(null); + + const { sendTransaction, balances, network, refreshBalance } = useWallet(); + + const availableBalance = balances.length > 0 ? balances[0].available : 0n; + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + reset, + } = useForm({ + resolver: zodResolver(createSendFormSchema(availableBalance, network)), + defaultValues: { + selectedToken: 'HTR', + amount: '', + address: '', + timelock: '', + dataOutput: '', + }, + mode: 'onChange', + }); + + const amount = watch('amount'); + + const handleMaxClick = () => { + if (availableBalance > 0n) { + setValue('amount', centsToHTR(availableBalance), { + shouldValidate: true + }); + } + }; + + const onSubmit = async (data: SendFormData) => { + setIsLoading(true); + setTransactionError(null); + + try { + const amountInCents = htrToCents(data.amount); + + const result = await sendTransaction({ + network, + outputs: [{ + address: data.address.trim(), + value: amountInCents.toString(), + token: TOKEN_IDS.HTR + }] + }); + + console.log('Transaction sent successfully:', result); + + // Refresh balance after successful transaction + await refreshBalance(); + + // Reset form and close + reset(); + setTransactionError(null); + onClose(); + } catch (err) { + console.error('Failed to send transaction:', err); + setTransactionError(err instanceof Error ? err.message : 'Failed to send transaction'); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Send Tokens

+ +
+ + {/* Form */} +
+ {/* Select Token */} +
+ + +
+ + {/* Amount */} +
+ +
+ + HTR +
+
+ + Balance available: {formatHTRAmount(availableBalance)} HTR + + +
+ {errors.amount && ( +
+ {errors.amount.message} +
+ )} +
+ + {/* Destination Address */} +
+ + + {errors.address && ( +
+ {errors.address.message} +
+ )} +
+ + {transactionError && ( +
+ {transactionError} +
+ )} + + {/* Advanced Options */} +
+ + + {showAdvanced && ( +
+ {/* Timelock */} +
+ + +
+ + {/* Data Output */} +
+ +