diff --git a/README.md b/README.md index 26eac419..8b79f58e 100644 --- a/README.md +++ b/README.md @@ -1,407 +1,80 @@ # Mimir Wallet -
- -![Mimir Wallet](./app/src/assets/images/logo.png) - [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/) -[![React](https://img.shields.io/badge/React-19.2-blue)](https://reactjs.org/) -[![Vite](https://img.shields.io/badge/Vite-7.2-blue)](https://vitejs.dev/) -[![Node.js](https://img.shields.io/badge/Node.js-22%2B-green)](https://nodejs.org/) -[![pnpm](https://img.shields.io/badge/pnpm-10.24-blue)](https://pnpm.io/) [![GitHub Stars](https://img.shields.io/github/stars/mimir-labs/mimir-wallet.svg)](https://github.com/mimir-labs/mimir-wallet/stargazers) +[![Node.js](https://img.shields.io/badge/Node.js-22%2B-green)](https://nodejs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/) -**The most comprehensive enterprise-level multi-signature wallet for Polkadot ecosystem** - -[🌐 Website](https://app.mimir.global) β€’ [πŸ“– Documentation](https://docs.mimir.global) β€’ [πŸ› Report Bug](https://github.com/mimir-labs/mimir-wallet/issues) β€’ [πŸ’‘ Request Feature](https://github.com/mimir-labs/mimir-wallet/issues) - -
- ---- - -## 🎯 Overview - -Mimir Wallet is a state-of-the-art Progressive Web Application (PWA) designed for enterprise-grade multi-signature wallet management in the Polkadot ecosystem. Built with modern web technologies and following best practices, it provides an intuitive interface for complex blockchain operations while maintaining the highest security standards. - -### πŸš€ Key Highlights - -- **Enterprise-Ready**: Production-grade multi-signature management with advanced security features -- **Cross-Chain Support**: Native support for Polkadot, Kusama, and all Substrate-based chains -- **Progressive Web App**: Installable app with offline capabilities and native-like experience -- **Modern Architecture**: Built with React 19, TypeScript 5.9, and Vite 7 for optimal performance -- **Monorepo Structure**: Scalable Turbo-powered architecture with modular packages - ---- - -## ✨ Core Features - -### πŸ” Enterprise Security -- **Multi-Signature Management** - - Flexible M-of-N signature schemes (2/3, 3/5, custom configurations) - - Hardware wallet integration (Ledger, Trezor) - - Role-based access control with granular permissions - - Secure transaction approval workflows - -### 🌐 Blockchain Integration -- **Universal Substrate Support** - - Native Polkadot and Kusama integration - - Support for 40+ substrate-based parachains - - Real-time chain state monitoring - - Chopsticks integration for transaction simulation - -### πŸ’Ό Professional Tools -- **Advanced Transaction Management** - - Batch transaction processing for efficiency - - Real-time transaction status tracking - - Gas fee estimation and optimization - - Transaction scheduling and automation - - Comprehensive audit trails and history - -### πŸ”§ Developer Experience -- **Modern Development Stack** - - TypeScript-first development with strict type safety - - Comprehensive React hooks for blockchain interactions - - Modular component library with ShadCN/UI + Radix UI - - Extensive testing coverage with Vitest and Cypress - -### 🎨 User Experience -- **Intuitive Interface** - - Responsive design optimized for all devices - - Dark/light theme support - - Accessibility-first component design - - Progressive Web App with offline capabilities - ---- - -## πŸš€ Quick Start - -### Prerequisites - -Ensure you have the following installed: - -- **Node.js** >= 22.0.0 ([Download](https://nodejs.org/)) -- **pnpm** v10.24+ ([Install](https://pnpm.io/installation)) -- **Git** ([Download](https://git-scm.com/)) -- **Modern Browser** (Chrome 61+, Firefox 60+, Safari 11+, Edge 18+) - -### πŸ”§ Installation - -1. **Clone the repository** - ```bash - git clone https://github.com/mimir-labs/mimir-wallet.git - cd mimir-wallet - ``` +Enterprise-grade multi-signature wallet for the Polkadot ecosystem. Built as a Progressive Web App with React, TypeScript, and Vite. -2. **Enable pnpm and install dependencies** - ```bash - corepack enable - pnpm install - ``` +**[Live Demo](https://app.mimir.global)** Β· [Documentation](https://docs.mimir.global) Β· [Report Bug](https://github.com/mimir-labs/mimir-wallet/issues) -3. **Start development server** - ```bash - pnpm dev - ``` +## Features -4. **Open your browser** - ``` - Navigate to http://localhost:5173 - ``` +- **Multi-Signature Management** - Flexible M-of-N signature schemes with hardware wallet support (Ledger) +- **Cross-Chain Operations** - XCM transfers across 40+ Polkadot parachains +- **Transaction Simulation** - Preview transaction effects with Chopsticks integration +- **Progressive Web App** - Installable with offline capabilities +- **Batch Transactions** - Execute multiple operations efficiently -### πŸ—οΈ Production Build +## Quick Start ```bash -# Build all packages for production -pnpm build +# Clone repository +git clone https://github.com/mimir-labs/mimir-wallet.git +cd mimir-wallet -# Preview the production build locally -cd app && pnpm preview -``` +# Install dependencies (requires Node.js 22+ and pnpm 10+) +corepack enable +pnpm install ---- +# Start development server +pnpm dev +``` -## πŸ“¦ Monorepo Architecture +Open http://localhost:5173 in your browser. -This project uses a **Turbo-powered monorepo** with the following structure: +## Project Structure ``` mimir-wallet/ -β”œβ”€β”€ app/ # Main wallet application +β”œβ”€β”€ app/ # Main wallet application (React + Vite PWA) β”œβ”€β”€ packages/ -β”‚ β”œβ”€β”€ polkadot-core/ # Blockchain integration layer -β”‚ β”œβ”€β”€ service/ # Service layer & data management -β”‚ β”œβ”€β”€ ui/ # Component library -β”‚ └── dev/ # Development tools & configs -β”œβ”€β”€ turbo.json # Turbo configuration -└── package.json # Root workspace configuration +β”‚ β”œβ”€β”€ polkadot-core/ # Blockchain integration (Polkadot.js API, Chopsticks) +β”‚ β”œβ”€β”€ service/ # HTTP client, React Query, WebSocket +β”‚ β”œβ”€β”€ ui/ # ShadCN/UI component library +β”‚ └── dev/ # ESLint, TypeScript configs ``` -### 🏠 Main Application - -**[`app/`](./app/)** - The primary Mimir Wallet application -- **Framework**: React 19.2 + TypeScript 5.9 + Vite 7.2 -- **Features**: PWA support, multi-chain wallet interface, responsive design -- **Build Output**: Optimized production build with code splitting - -### πŸ“š Core Packages - -#### [`@mimir-wallet/polkadot-core`](./packages/polkadot-core/) -The foundational blockchain integration package -- **Polkadot.js API** v16.x integration -- **Multi-chain API management** for Polkadot ecosystem -- **Transaction processing** with dry-run capabilities -- **Chopsticks integration** for fork simulation and testing -- **React hooks** for seamless blockchain state management -- **Comprehensive testing**: 202 unit tests + 64 integration tests (Paseo testnet) - -#### [`@mimir-wallet/service`](./packages/service/) -Service layer and data management -- **Client-side service architecture** with dependency injection -- **React Query integration** for efficient server state management -- **Storage management** (localStorage, sessionStorage, IndexedDB) -- **WebSocket support** for real-time blockchain updates +## Tech Stack -#### [`@mimir-wallet/ui`](./packages/ui/) -Modern React UI component library built on ShadCN/UI architecture -- **ShadCN/UI + Radix UI** unstyled, accessible components as foundation -- **Radix UI primitives** for industry-leading accessibility -- **Tailwind CSS v4.1** for utility-first styling with modern features -- **Class Variance Authority (CVA)** for type-safe component variants -- **TypeScript-first** design system with strict typing -- **Specialized blockchain components** (Address, Balance, etc.) -- **Framer Motion** animations for enhanced UX +**Frontend**: React, TypeScript, Vite, Tailwind CSS, ShadCN/UI +**Blockchain**: Polkadot.js API, Chopsticks, WalletConnect +**State**: TanStack Query, Zustand, TanStack Router +**Build**: Turbo, pnpm, Vitest -#### [`@mimir-wallet/dev`](./packages/dev/) -Development tooling and shared configurations -- **ESLint v9** configurations with modern rules -- **TypeScript configurations** for consistent builds -- **Build tools and utilities** for monorepo management +## Development ---- - -## πŸ›  Technology Stack - -### Frontend & UI -| Technology | Version | Purpose | -|------------|---------|---------| -| **React** | 19.2 | UI framework with concurrent features | -| **TypeScript** | 5.9 | Type-safe development | -| **Vite** | 7.2 | Fast build tool and dev server | -| **ShadCN/UI + Radix UI** | Latest | Accessible component library | -| **Tailwind CSS** | 4.1 | Utility-first styling | -| **Framer Motion** | 12.12 | Smooth animations | - -### Blockchain Integration -| Technology | Version | Purpose | -|------------|---------|---------| -| **Polkadot.js API** | 16.5.2 | Substrate blockchain interaction | -| **Chopsticks** | 1.0.1 | Fork simulation for testing | -| **WalletConnect** | 2.15.1 | Cross-wallet compatibility | - -### State Management & Data -| Technology | Version | Purpose | -|------------|---------|---------| -| **TanStack Query** | 5.x | Server state management | -| **Zustand** | 5.0 | Client state management | -| **TanStack Router** | 1.134 | Type-safe client-side routing | -| **Socket.io Client** | Latest | Real-time communication | - -### Build & Development -| Technology | Version | Purpose | -|------------|---------|---------| -| **Turbo** | 2.6.3 | Monorepo build system | -| **pnpm** | 10.24 | Package management | -| **ESLint** | 9.21 | Code linting and formatting | -| **Vitest** | 4.0 | Unit & integration testing framework | -| **Cypress** | 13.13 | End-to-end testing | - -### PWA & Performance -| Technology | Version | Purpose | -|------------|---------|---------| -| **Vite PWA** | 1.1.0 | Progressive Web App features | -| **Workbox** | 7.3.0 | Service worker management | -| **Chart.js** | 4.4.9 | Data visualization | - ---- - -## πŸ“œ Available Scripts - -### πŸ”§ Monorepo Management ```bash -# Development -pnpm dev # Start all development servers -pnpm build # Build all packages for production -pnpm check-types # Run TypeScript checks across all packages - -# Code Quality -pnpm lint # Run ESLint across all packages -pnpm commit # Create conventional commit (uses Commitizen) - -# Package Management -pnpm --filter # Run command in specific workspace +pnpm dev # Start dev server +pnpm build # Production build +pnpm check-types # TypeScript check +pnpm lint # ESLint +pnpm test # Run tests ``` -### 🎯 Application Commands -```bash -# Navigate to app directory first: cd app/ - -pnpm dev # Start app development server (Vite) -pnpm build # Build app for production -pnpm preview # Preview production build -pnpm check-types # TypeScript type checking -``` - -### πŸ“¦ Package Development -```bash -# Navigate to specific package: cd packages// - -pnpm dev # Start package in watch mode -pnpm build # Build package -pnpm check-types # Check TypeScript types -``` - -### πŸ§ͺ Testing -```bash -# Run all tests (from root) -pnpm test # Run all tests across packages (via Turbo) -pnpm test:cov # Run all tests with coverage report - -# Package-specific testing (from packages/polkadot-core/) -pnpm test # Run unit tests -pnpm test:unit # Run unit tests (explicit) -pnpm test:integration # Run integration tests (connects to Paseo testnet) -pnpm test:watch # Run tests in watch mode - -# E2E Testing (from app/) -pnpm cypress:open # Open Cypress test runner -pnpm cypress:run # Run Cypress tests headlessly -``` - ---- - -## 🌐 Browser Support - -### Production Environment -| Browser | Minimum Version | Features | -|---------|-----------------|----------| -| **Chrome** | 61+ | Full PWA support, hardware wallets | -| **Firefox** | 60+ | Full functionality | -| **Safari** | 11+ | PWA support (limited) | -| **Edge** | 18+ | Full functionality | -| **Opera** | 48+ | Full functionality | - -### Development Environment -- Latest Chrome (recommended) -- Latest Firefox - -### PWA Features -- βœ… **Installable**: Add to home screen on mobile/desktop -- βœ… **Offline Support**: Basic functionality without internet -- βœ… **Background Sync**: Transaction status updates -- βœ… **Push Notifications**: Transaction confirmations (when supported) - ---- - -## 🎨 Key Features Deep Dive - -### Multi-Signature Management -- **Flexible Thresholds**: Support for any M-of-N configuration -- **Member Management**: Add/remove signatories with proper governance -- **Transaction Approval**: Streamlined approval workflow with status tracking -- **Batch Operations**: Execute multiple transactions efficiently - -### Cross-Chain Operations -- **Asset Management**: View and manage assets across multiple chains -- **Cross-Chain Transfers**: XCM support for inter-parachain transfers -- **Chain Switching**: Seamless switching between supported networks -- **Real-Time Updates**: Live chain state and transaction monitoring - -### Security Features -- **Hardware Wallet Support**: Ledger and Trezor integration -- **Transaction Simulation**: Preview transaction effects before execution -- **Audit Trails**: Comprehensive transaction history and logs -- **Secure Storage**: Client-side key management with encryption - ---- - -## 🀝 Contributing - -We welcome contributions from the community! Here's how you can help: - -### Development Process - -1. **Fork the repository** - ```bash - git clone https://github.com//mimir-wallet.git - ``` - -2. **Create a feature branch** - ```bash - git checkout -b feature/amazing-feature - ``` - -3. **Make your changes** - - Follow the existing code style and conventions - - Add tests for new functionality - - Update documentation as needed - -4. **Commit using Conventional Commits** - ```bash - pnpm commit # Uses Commitizen for proper formatting - ``` - -5. **Push and create a Pull Request** - ```bash - git push origin feature/amazing-feature - ``` - -### Code Standards - -- **TypeScript**: Strict type checking enabled -- **ESLint**: Enforced code style and best practices -- **Prettier**: Automatic code formatting -- **Conventional Commits**: Standardized commit messages -- **Testing**: Unit tests required for new features - -### Areas for Contribution - -- πŸ› **Bug Fixes**: Help us identify and fix issues -- ✨ **New Features**: Propose and implement new wallet features -- πŸ“– **Documentation**: Improve guides and API documentation -- πŸ§ͺ **Testing**: Increase test coverage and add E2E tests -- 🎨 **UI/UX**: Enhance user interface and experience -- πŸ”§ **Performance**: Optimize bundle size and runtime performance - ---- - -## πŸ“„ License - -This project is licensed under the **Apache License 2.0** - see the [LICENSE](LICENSE) file for details. - ---- - -## πŸ”— Links & Resources - -### Official Links -- 🌐 **Website**: [mimir.global](https://www.mimir.global) -- πŸ“– **Documentation**: [docs.mimir.global](https://docs.mimir.global) -- πŸ“± **GitHub**: [mimir-labs/mimir-wallet](https://github.com/mimir-labs/mimir-wallet) -- πŸ› **Issues**: [Bug Reports & Feature Requests](https://github.com/mimir-labs/mimir-wallet/issues) - -### Community & Support -- πŸ’¬ **Telegram**: [Join our community](https://t.me/+t7vZ1kXV5h1kNGQ9) -- 🐦 **Twitter**: [@Mimir_global](https://twitter.com/Mimir_global) - -### Development Resources -- πŸ“¦ **Releases**: [GitHub Releases](https://github.com/mimir-labs/mimir-wallet/releases) +## Contributing ---- +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit using conventional commits (`pnpm commit`) +4. Open a Pull Request -
+## Community -**Made with ❀️ by [Mimir Labs](https://mimir.global)** +- [Telegram](https://t.me/+t7vZ1kXV5h1kNGQ9) +- [Twitter](https://twitter.com/Mimir_global) -⭐ Star us on GitHub if you find this project useful! +## License -
+[Apache License 2.0](LICENSE) diff --git a/app/package.json b/app/package.json index bb449094..a25838f0 100644 --- a/app/package.json +++ b/app/package.json @@ -63,9 +63,9 @@ "papaparse": "^5.4.1", "posthog-js": "^1.258.5", "qrcode-generator": "^1.4.4", - "react": "^19.2.1", + "react": "^19.2.3", "react-chartjs-2": "^5.3.0", - "react-dom": "^19.2.1", + "react-dom": "^19.2.3", "react-ga4": "^2.1.0", "react-intersection-observer": "^10.0.0", "react-json-view": "^1.21.3", diff --git a/app/public/dapp-icons/cross-chain-swap.svg b/app/public/dapp-icons/cross-chain-swap.svg new file mode 100644 index 00000000..b5f16a99 --- /dev/null +++ b/app/public/dapp-icons/cross-chain-swap.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/apps/cross-chain-swap/SlippageSettingDialog.tsx b/app/src/apps/cross-chain-swap/SlippageSettingDialog.tsx new file mode 100644 index 00000000..5c447f15 --- /dev/null +++ b/app/src/apps/cross-chain-swap/SlippageSettingDialog.tsx @@ -0,0 +1,183 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SlippagePreset, SlippageState } from './types'; + +import { + Button, + Divider, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Tooltip, +} from '@mimir-wallet/ui'; +import { useState } from 'react'; + +import IconQuestion from '@/assets/svg/icon-question-fill.svg?react'; + +const SLIPPAGE_PRESETS: SlippagePreset[] = ['0.1', '1', '5']; + +interface SlippageSettingDialogProps { + open: boolean; + value: SlippageState; + onChange: (slippage: SlippageState) => void; + onClose: () => void; +} + +/** + * Slippage preset/custom item component + * Follows exact same pattern as add-proxy DelayItem + */ +function SlippageItem({ + preset, + isSelected, + isCustom, + customValue, + onSelect, + onCustomChange, +}: { + preset?: SlippagePreset; + isSelected: boolean; + isCustom?: boolean; + customValue?: string; + onSelect: () => void; + onCustomChange?: (value: string) => void; +}) { + const isCustomSelected = isSelected && isCustom; + + return ( +
+ {isCustomSelected ? ( + <> + onCustomChange(e.target.value))} + onClick={(e) => e.stopPropagation()} + className="text-foreground border-divider bg-primary-foreground h-[27px] shrink grow rounded-full border px-2.5 outline-none" + placeholder="0" + /> + % + + ) : isCustom ? ( + 'Customize' + ) : ( + `${preset}%` + )} +
+ ); +} + +function SlippageSettingDialog({ + open, + value, + onChange, + onClose, +}: SlippageSettingDialogProps) { + // Local state - synced from props when value changes externally + const [localValue, setLocalValue] = useState(value); + + const valueKey = `${value.type}:${value.value}`; + const localKey = `${localValue.type}:${localValue.value}`; + + if (valueKey !== localKey && !open) { + setLocalValue(value); + } + + // Handlers for local state + const handlePresetSelect = (preset: SlippagePreset) => { + setLocalValue({ type: 'preset', value: preset }); + }; + + const handleCustomSelect = () => { + // Switch to custom mode, keep current custom value or default to '1' + setLocalValue((prev) => ({ + type: 'custom', + value: prev.type === 'custom' ? prev.value : '1', + })); + }; + + const handleCustomChange = (inputValue: string) => { + // Validate numeric input + const numValue = parseFloat(inputValue); + + if (inputValue && (isNaN(numValue) || numValue < 0 || numValue > 50)) { + return; + } + + setLocalValue({ type: 'custom', value: inputValue }); + }; + + // Handle confirm - call onChange with local value + const handleConfirm = () => { + // If custom is selected but empty, default to 1% + if (localValue.type === 'custom' && !localValue.value) { + onChange({ type: 'preset', value: '1' }); + } else { + onChange(localValue); + } + + onClose(); + }; + + // Derived state from local value + const isPresetSelected = (preset: SlippagePreset) => + localValue.type === 'preset' && localValue.value === preset; + + const isCustomSelected = localValue.type === 'custom'; + + return ( + + + Max Slippage + + + {/* Slippage label with tooltip */} +
+ Slippage + + + +
+ + {/* Slippage options row */} +
+ {SLIPPAGE_PRESETS.map((preset) => ( + handlePresetSelect(preset)} + /> + ))} + +
+
+ + + + +
+
+ ); +} + +export default SlippageSettingDialog; diff --git a/app/src/apps/cross-chain-swap/SwapFeeInfo.tsx b/app/src/apps/cross-chain-swap/SwapFeeInfo.tsx new file mode 100644 index 00000000..72e4e2e2 --- /dev/null +++ b/app/src/apps/cross-chain-swap/SwapFeeInfo.tsx @@ -0,0 +1,133 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SwapRouteStep } from './types'; +import type { XcmFeeAsset } from '@mimir-wallet/polkadot-core'; + +import { Avatar, cn, Tooltip } from '@mimir-wallet/ui'; +import React from 'react'; + +import IconQuestion from '@/assets/svg/icon-question-fill.svg?react'; +import FormatBalance from '@/components/FormatBalance'; + +interface SwapFeeInfoProps { + time?: string; + originFee?: XcmFeeAsset; + destFee?: XcmFeeAsset; + route?: SwapRouteStep[]; + exchangeRate?: string; +} + +function FeeRow({ + icon, + label, + value, + tooltip, + valueClassName, +}: { + icon: string; + label: string; + value?: React.ReactNode; + tooltip?: string; + valueClassName?: string; +}) { + return ( +
+
+ {icon} + {label} + {tooltip && ( + + + + )} +
+ {value ?? '--'} +
+ ); +} + +function RouteDisplay({ route }: { route: SwapRouteStep[] }) { + if (!route || route.length === 0) return --; + + return ( +
+ {route.map((step, index) => ( + + + {index < route.length - 1 && ( + β†’ + )} + + ))} +
+ ); +} + +function SwapFeeInfo({ + time, + originFee, + destFee, + route, + exchangeRate, +}: SwapFeeInfoProps) { + return ( +
+ + + ) : undefined + } + tooltip="Fee paid on the source chain" + /> + + ) : undefined + } + tooltip="Fee paid on the destination chain" + /> + } + tooltip="The path your tokens will take across chains" + /> + {exchangeRate && ( + + )} +
+ ); +} + +export default SwapFeeInfo; diff --git a/app/src/apps/cross-chain-swap/SwapForm.tsx b/app/src/apps/cross-chain-swap/SwapForm.tsx new file mode 100644 index 00000000..a49fac0f --- /dev/null +++ b/app/src/apps/cross-chain-swap/SwapForm.tsx @@ -0,0 +1,284 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { + buildSwapCall, + NetworkProvider, + useChain, +} from '@mimir-wallet/polkadot-core'; +import { Alert, AlertTitle, Button, Divider } from '@mimir-wallet/ui'; +import { useCallback, useEffect, useState } from 'react'; + +import SlippageSettingDialog from './SlippageSettingDialog'; +import SwapFeeInfo from './SwapFeeInfo'; +import { useSwapFormContext } from './SwapFormContext'; +import ToTokenSection from './ToTokenSection'; +import { useSwapEstimate } from './useSwapEstimate'; + +import IconSet from '@/assets/svg/icon-set.svg?react'; +import IconTransfer from '@/assets/svg/icon-transfer.svg?react'; +import { TxButton } from '@/components'; +import AddressCell from '@/components/AddressCell'; +import InputAddress from '@/components/InputAddress'; +import { + AmountInput, + InputNetworkToken, + itemToValue, +} from '@/components/InputNetworkToken'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { parseUnits } from '@/utils'; + +/** + * Swap form with From/To token selectors and swap logic + */ +function SwapForm() { + const upSm = useMediaQuery('sm'); + + // Get all form state from SwapFormContext + const { + sending, + supportedNetworks, + fromToken, + fromNetwork, + setFromValue, + toValue, + setToValue, + toToken, + amount, + isAmountValid, + setAmount, + slippage, + setSlippage, + recipient, + setRecipient, + } = useSwapFormContext(); + + // Dialog states + const [slippageDialogOpen, setSlippageDialogOpen] = useState(false); + + // Get chain configurations + const fromChain = useChain(fromNetwork); + const toChain = useChain(toValue?.network || undefined); + + // Swap estimate + const estimate = useSwapEstimate({ + fromChain, + toChain, + fromToken, + toToken, + amount, + slippage, + senderAddress: sending, + recipient, + }); + + // Handle swap button click (interchange From/To) + const handleSwap = useCallback(() => { + if (!fromToken || !toToken) return; + + // Save current values + const currentFromValue = itemToValue(fromToken); + const currentToValue = toValue; + + // Swap + if (currentToValue) { + setFromValue(currentToValue); + } + + setToValue(currentFromValue); + setAmount(''); + }, [fromToken, toToken, toValue, setFromValue, setToValue, setAmount]); + + // Form validation + const isFormValid = + !!fromToken && + !!toToken && + !!amount && + isAmountValid && + !!recipient && + !estimate.isLoading && + !estimate.error && + estimate.dryRunSuccess; + + // Reset amount when tokens change + useEffect(() => { + setAmount(''); + }, [fromNetwork, fromToken?.token.key, setAmount]); + + // Build swap transaction using ParaSpell XCM Router + const getCall = useCallback(async () => { + if (!fromChain || !toChain || !fromToken || !toToken || !amount) { + throw new Error('Missing required parameters'); + } + + // Parse amount to smallest unit + const amountInSmallestUnit = parseUnits( + amount, + fromToken.token.decimals, + ).toString(); + + // Build swap transactions (may return multiple for two-click DEXes) + const transactions = await buildSwapCall({ + fromChain, + toChain, + fromToken: fromToken.token, + toToken: toToken.token, + amount: amountInSmallestUnit, + slippagePct: slippage.value, + senderAddress: sending, + recipient, + exchange: estimate.exchange, + }); + + if (transactions.length === 0) { + throw new Error('No swap route found'); + } + + // Return the first transaction for now + return transactions[0].tx; + }, [ + fromChain, + toChain, + fromToken, + toToken, + amount, + slippage.value, + sending, + recipient, + estimate.exchange, + ]); + + return ( +
+ {/* Header with gradient title */} +

+ Cross-chain Swap +

+ + + + {/* Sending from */} +
+ +
+ +
+
+ + {/* Transfer section */} +
+ {/* Transfer label with settings icon */} +
+ Transfer + +
+ + {/* From row - InputNetworkToken with AmountInput as children */} + + + + + {/* Swap button */} +
+ +
+ + {/* To row - InputNetworkToken with output amount display */} + +
+ + {/* Recipient */} + + + {/* Fee Info */} + {!estimate.isLoading && estimate.isFetched && ( + + )} + + {/* Error Alert */} + {(estimate.error || estimate.dryRunError) && ( + + {estimate.error || estimate.dryRunError} + + )} + + + + {/* Confirm Button */} + + + {estimate.isLoading ? ( + Calculating Fee + ) : ( + 'Confirm' + )} + + + + {/* Slippage Setting Dialog */} + setSlippageDialogOpen(false)} + /> +
+ ); +} + +export default SwapForm; diff --git a/app/src/apps/cross-chain-swap/SwapFormContext.tsx b/app/src/apps/cross-chain-swap/SwapFormContext.tsx new file mode 100644 index 00000000..651eda82 --- /dev/null +++ b/app/src/apps/cross-chain-swap/SwapFormContext.tsx @@ -0,0 +1,224 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SlippageState } from './types'; +import type { + TokenNetworkItem, + TokenNetworkValue, +} from '@/components/InputNetworkToken'; +import type { Endpoint } from '@mimir-wallet/polkadot-core'; + +import { useLocalStore } from '@mimir-wallet/service'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { DEFAULT_SLIPPAGE } from './types'; + +import { + findItemByValue, + InputNetworkTokenProvider, + useInputNetworkTokenContext, + useTokenNetworkData, +} from '@/components/InputNetworkToken'; +import { CROSS_CHAIN_SWAP_SLIPPAGE_KEY } from '@/constants'; +import { useInputNumber } from '@/hooks/useInputNumber'; + +/** + * SwapForm context value type + */ +interface SwapFormContextValue { + // Sender + sending: string; + + // Supported networks (Polkadot only) + supportedNetworks?: string[]; + + // From token state (from InputNetworkTokenContext) + fromToken: TokenNetworkItem | undefined; + fromNetwork: string; + setFromValue: (value: TokenNetworkValue) => void; + + // To token state + toValue: TokenNetworkValue | undefined; + setToValue: (value: TokenNetworkValue | undefined) => void; + toToken: TokenNetworkItem | undefined; + toItems: TokenNetworkItem[]; + toNetworks: Endpoint[]; + isToItemsLoading: boolean; + + // Amount + amount: string; + isAmountValid: boolean; + setAmount: (amount: string) => void; + + // Slippage + slippage: SlippageState; + setSlippage: (slippage: SlippageState) => void; + + // Recipient + recipient: string; + setRecipient: (recipient: string) => void; +} + +const SwapFormContext = createContext(null); + +/** + * Inner provider that has access to InputNetworkTokenContext + */ +function SwapFormContextProvider({ + sending, + supportedNetworks, + children, +}: { + sending: string; + supportedNetworks?: string[]; + children: React.ReactNode; +}) { + // From token state (from InputNetworkTokenContext) + const { + token: fromToken, + network: fromNetwork, + setValue: setFromValue, + } = useInputNetworkTokenContext(); + + // To token data - fetched once and shared (filtered by supportedNetworks) + const { + items: toItems, + networks: toNetworks, + isLoading: isToItemsLoading, + } = useTokenNetworkData(sending, { + xcmOnly: true, + includeAllAssets: true, + supportedNetworks, + tokenFilter: (item) => + (item.token.price && item.token.price > 0) || !!item.token.logoUri, + }); + + // To token state + const [toValue, setToValue] = useState(); + const toToken = useMemo( + () => (toValue ? findItemByValue(toItems, toValue) : undefined), + [toItems, toValue], + ); + + // Amount state + const [[amount, isAmountValid], setAmountState] = useInputNumber( + '', + false, + 0, + ); + const setAmount = useCallback( + (value: string) => setAmountState(value), + [setAmountState], + ); + + // Slippage state (persisted to localStorage) + const [slippage, setSlippage] = useLocalStore( + CROSS_CHAIN_SWAP_SLIPPAGE_KEY, + DEFAULT_SLIPPAGE, + ); + + // Recipient state (defaults to sender address) + const [recipient, setRecipient] = useState(sending); + + const value = useMemo( + () => ({ + sending, + supportedNetworks, + fromToken, + fromNetwork, + setFromValue, + toValue, + setToValue, + toToken, + toItems, + toNetworks, + isToItemsLoading, + amount, + isAmountValid, + setAmount, + slippage, + setSlippage, + recipient, + setRecipient, + }), + [ + sending, + supportedNetworks, + fromToken, + fromNetwork, + setFromValue, + toValue, + toToken, + toItems, + toNetworks, + isToItemsLoading, + amount, + isAmountValid, + setAmount, + slippage, + setSlippage, + recipient, + ], + ); + + return ( + + {children} + + ); +} + +interface SwapFormProviderProps { + address: string; + supportedNetworks?: string[]; + defaultNetwork?: string; + children: React.ReactNode; +} + +/** + * Provider for swap form state + * Wraps InputNetworkTokenProvider and manages all swap form state + */ +export function SwapFormProvider({ + address, + supportedNetworks, + defaultNetwork, + children, +}: SwapFormProviderProps) { + return ( + + + {children} + + + ); +} + +/** + * Hook to access swap form context + * Must be used within SwapFormProvider + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useSwapFormContext(): SwapFormContextValue { + const context = useContext(SwapFormContext); + + if (!context) { + throw new Error('useSwapFormContext must be used within SwapFormProvider'); + } + + return context; +} diff --git a/app/src/apps/cross-chain-swap/ToTokenSection.tsx b/app/src/apps/cross-chain-swap/ToTokenSection.tsx new file mode 100644 index 00000000..96bbd8da --- /dev/null +++ b/app/src/apps/cross-chain-swap/ToTokenSection.tsx @@ -0,0 +1,93 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { TokenNetworkValue } from '@/components/InputNetworkToken'; + +import { cn, Spinner } from '@mimir-wallet/ui'; + +import FormatBalance from '@/components/FormatBalance'; +import { + InputNetworkToken, + InputNetworkTokenProvider, + useInputNetworkTokenContext, +} from '@/components/InputNetworkToken'; + +/** + * Output Amount Display Component + * Shows the estimated output amount (read-only) in the To token section + */ +function OutputAmountDisplay({ + outputAmount, + outputLoading, +}: { + outputAmount?: string; + outputLoading?: boolean; +}) { + const { token } = useInputNetworkTokenContext(); + + return ( +
e.stopPropagation()} + className={cn( + 'ml-auto min-w-0 text-right text-sm font-bold', + !outputAmount && 'text-foreground/50', + )} + > + {outputLoading ? ( + + ) : outputAmount && token ? ( + + ) : ( + '0.0' + )} +
+ ); +} + +export interface ToTokenSectionProps { + address?: string; + supportedNetworks?: string[]; + value: TokenNetworkValue | undefined; + onChange: (value: TokenNetworkValue) => void; + outputAmount?: string; + outputLoading?: boolean; +} + +/** + * To Token Section Component + * Uses controlled mode - value and onChange are passed to InputNetworkTokenProvider + */ +function ToTokenSection({ + address, + supportedNetworks, + value, + onChange, + outputAmount, + outputLoading, +}: ToTokenSectionProps) { + return ( + + (item.token.price && item.token.price > 0) || !!item.token.logoUri + } + > + + + + + ); +} + +export default ToTokenSection; diff --git a/app/src/apps/cross-chain-swap/index.tsx b/app/src/apps/cross-chain-swap/index.tsx new file mode 100644 index 00000000..bef94de1 --- /dev/null +++ b/app/src/apps/cross-chain-swap/index.tsx @@ -0,0 +1,48 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { useChains } from '@mimir-wallet/polkadot-core'; + +import SwapForm from './SwapForm'; +import { SwapFormProvider } from './SwapFormContext'; + +import { useAccount } from '@/accounts/useAccount'; +import { useInputNetwork } from '@/hooks/useInputNetwork'; + +/** + * Check if a network is Polkadot or a Polkadot parachain + */ +function isPolkadotNetwork(network: { key: string; relayChain?: string }) { + return network.relayChain === 'polkadot' || network.key === 'polkadot'; +} + +/** + * Main Cross-chain Swap DApp entry point + */ +function CrossChainSwap() { + const { current } = useAccount(); + const { chains } = useChains(); + + // Filter to only Polkadot networks (relay chain + parachains) + const supportedNetworks = chains + .filter(isPolkadotNetwork) + .map((item) => item.key); + + const [initialNetwork] = useInputNetwork(undefined, supportedNetworks); + + if (!current) { + return null; + } + + return ( + + + + ); +} + +export default CrossChainSwap; diff --git a/app/src/apps/cross-chain-swap/types.ts b/app/src/apps/cross-chain-swap/types.ts new file mode 100644 index 00000000..7e643693 --- /dev/null +++ b/app/src/apps/cross-chain-swap/types.ts @@ -0,0 +1,65 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Endpoint, XcmFeeAsset } from '@mimir-wallet/polkadot-core'; + +/** + * Swap route step representing a hop in the swap path + */ +export interface SwapRouteStep { + /** Resolved Endpoint (for displaying icon and name) */ + network: Endpoint; + /** Token symbol displayed for this step */ + token?: string; + /** Whether this step is an exchange (DEX) */ + isExchange?: boolean; +} + +/** + * Slippage setting type + */ +export type SlippagePreset = '0.1' | '1' | '5'; + +export interface SlippageState { + type: 'preset' | 'custom'; + value: string; // percentage string +} + +/** + * Swap estimate result from mock/real API + */ +export interface SwapEstimate { + // Output amount + outputAmount: string; + + // Fees + originFee?: XcmFeeAsset; + destFee?: XcmFeeAsset; + + // Route info + route: SwapRouteStep[]; + estimatedTime: string; + + // Exchange used for swap + exchange: string; + + // Exchange rate display string + exchangeRate: string; + + // Dry-run validation result + dryRunSuccess: boolean; + dryRunError?: string; + + // States + isLoading: boolean; + isFetched: boolean; + error?: string; +} + +/** + * Default slippage value + */ +export const DEFAULT_SLIPPAGE: SlippageState = { + type: 'preset', + value: '1', +}; diff --git a/app/src/apps/cross-chain-swap/useSwapEstimate.ts b/app/src/apps/cross-chain-swap/useSwapEstimate.ts new file mode 100644 index 00000000..5ed3d06c --- /dev/null +++ b/app/src/apps/cross-chain-swap/useSwapEstimate.ts @@ -0,0 +1,240 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SlippageState, SwapEstimate, SwapRouteStep } from './types'; +import type { TokenNetworkItem } from '@/components/InputNetworkToken'; +import type { Endpoint, SwapRouteHop } from '@mimir-wallet/polkadot-core'; + +import { + allEndpoints, + getSwapEstimate as getSwapEstimateApi, +} from '@mimir-wallet/polkadot-core'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useDebounce } from 'react-use'; + +import { parseUnits } from '@/utils'; + +/** + * Debounce delay for inputs (ms) + */ +const DEBOUNCE_MS = 500; + +/** + * Parameters for useSwapEstimate hook + */ +export interface UseSwapEstimateParams { + fromChain?: Endpoint; + toChain?: Endpoint; + fromToken?: TokenNetworkItem; + toToken?: TokenNetworkItem; + amount: string; + slippage: SlippageState; + senderAddress: string; + recipient: string; +} + +/** + * Cached endpoint map for O(1) lookup by paraspellChain + */ +const endpointByParaspellChain = new Map( + allEndpoints + .filter((e) => e.paraspellChain) + .map((e) => [e.paraspellChain!, e]), +); + +/** + * Build swap route display from API hops + */ +function buildSwapRoute( + fromChain: Endpoint, + toChain: Endpoint, + fromToken: TokenNetworkItem, + toToken: TokenNetworkItem, + hops: SwapRouteHop[], +): SwapRouteStep[] { + const route: SwapRouteStep[] = [ + // Origin chain + { + network: fromChain, + token: fromToken.token.symbol, + isExchange: false, + }, + ]; + + // Add intermediate hops + for (const hop of hops) { + const endpoint = endpointByParaspellChain.get(hop.chain); + + if (endpoint) { + route.push({ + network: endpoint, + token: hop.isExchange ? 'SWAP' : undefined, + isExchange: hop.isExchange, + }); + } + } + + // Destination chain + route.push({ + network: toChain, + token: toToken.token.symbol, + isExchange: false, + }); + + return route; +} + +/** + * Calculate estimated time based on route length + */ +function getEstimatedTime(routeLength: number): string { + if (routeLength <= 2) return '~15s'; + if (routeLength === 3) return '~30s'; + + return '~45s'; +} + +/** + * Parse amount string to smallest unit + */ +function parseAmountToSmallestUnit( + amount: string, + decimals: number | undefined, +): string { + if (!amount || !decimals) return ''; + + try { + return parseUnits(amount, decimals).toString(); + } catch { + return ''; + } +} + +/** + * Hook for swap fee estimation and output calculation + */ +export function useSwapEstimate({ + fromChain, + toChain, + fromToken, + toToken, + amount, + slippage, + senderAddress, + recipient, +}: UseSwapEstimateParams): SwapEstimate { + // Debounced state - all params combined + const [debouncedParams, setDebouncedParams] = useState({ + fromChain, + toChain, + fromToken, + toToken, + amount, + }); + + // Single debounce effect for all params + useDebounce( + () => { + setDebouncedParams({ fromChain, toChain, fromToken, toToken, amount }); + }, + DEBOUNCE_MS, + [fromChain, toChain, fromToken, toToken, amount], + ); + + // Parse amount to smallest unit + const amountInSmallestUnit = parseAmountToSmallestUnit( + debouncedParams.amount, + debouncedParams.fromToken?.token.decimals, + ); + + // Check if query should be enabled + const isQueryEnabled = + !!debouncedParams.fromChain && + !!debouncedParams.toChain && + !!debouncedParams.fromToken && + !!debouncedParams.toToken && + !!amountInSmallestUnit && + !!senderAddress && + !!recipient; + + // Query for swap estimate + const { + data, + isLoading, + isFetched, + error: queryError, + } = useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Using .key for cache identity, full objects in queryFn + queryKey: [ + 'swap-estimate', + debouncedParams.fromChain?.key, + debouncedParams.toChain?.key, + debouncedParams.fromToken?.key, + debouncedParams.toToken?.key, + amountInSmallestUnit, + slippage.value, + senderAddress, + recipient, + ], + enabled: isQueryEnabled, + staleTime: 30_000, + retry: 1, + queryFn: async () => { + if ( + !debouncedParams.fromChain || + !debouncedParams.toChain || + !debouncedParams.fromToken || + !debouncedParams.toToken + ) { + throw new Error('Missing required parameters'); + } + + const result = await getSwapEstimateApi({ + fromChain: debouncedParams.fromChain, + toChain: debouncedParams.toChain, + fromToken: debouncedParams.fromToken.token, + toToken: debouncedParams.toToken.token, + amount: amountInSmallestUnit, + slippagePct: slippage.value, + senderAddress, + recipient, + }); + + const route = buildSwapRoute( + debouncedParams.fromChain!, + debouncedParams.toChain!, + debouncedParams.fromToken!, + debouncedParams.toToken!, + result.hops, + ); + + return { + outputAmount: result.outputAmount, + originFee: result.originFee, + destFee: result.destFee, + route, + estimatedTime: getEstimatedTime(route.length), + exchange: result.exchange, + exchangeRate: result.exchangeRate, + dryRunSuccess: result.dryRunSuccess, + dryRunError: result.dryRunError, + }; + }, + }); + + return { + outputAmount: data?.outputAmount ?? '0', + originFee: data?.originFee, + destFee: data?.destFee, + route: data?.route ?? [], + estimatedTime: data?.estimatedTime ?? '~22s', + exchange: data?.exchange ?? '', + exchangeRate: data?.exchangeRate ?? '', + dryRunSuccess: data?.dryRunSuccess ?? false, + dryRunError: data?.dryRunError, + isLoading, + isFetched, + error: queryError?.message, + }; +} diff --git a/app/src/apps/cross-chain-transfer/index.tsx b/app/src/apps/cross-chain-transfer/index.tsx index c3225833..2df7216d 100644 --- a/app/src/apps/cross-chain-transfer/index.tsx +++ b/app/src/apps/cross-chain-transfer/index.tsx @@ -238,7 +238,7 @@ function CrossChainTransferForm({ getCall={getCall} > {isLoading ? ( - Loading + Calculating Fee ) : ( 'Confirm' )} diff --git a/app/src/apps/cross-chain-transfer/useXcmFeeEstimate.ts b/app/src/apps/cross-chain-transfer/useXcmFeeEstimate.ts index 8a3101c8..6ae8931d 100644 --- a/app/src/apps/cross-chain-transfer/useXcmFeeEstimate.ts +++ b/app/src/apps/cross-chain-transfer/useXcmFeeEstimate.ts @@ -128,8 +128,8 @@ export function useXcmFeeEstimate({ retry: 1, queryFn: async () => { return getOriginXcmFee({ - sourceChain: fromChain!, - destChain: toChain!, + fromChain: fromChain!, + toChain: toChain!, token: token!.token, recipient, senderAddress, @@ -227,8 +227,8 @@ export function useXcmFeeEstimate({ } return buildXcmCall({ - sourceChain: fromChain, - destChain: toChain, + fromChain: fromChain, + toChain: toChain, token: tokenData, amount: amountInSmallestUnit, recipient, diff --git a/app/src/apps/transfer/TransferContent.tsx b/app/src/apps/transfer/TransferContent.tsx index 288b01e1..50498ea7 100644 --- a/app/src/apps/transfer/TransferContent.tsx +++ b/app/src/apps/transfer/TransferContent.tsx @@ -108,7 +108,12 @@ function TransferContent({ withCopy withAddressBook /> - diff --git a/app/src/assets/svg/icon-swap.svg b/app/src/assets/svg/icon-swap.svg new file mode 100644 index 00000000..ec5a6e3c --- /dev/null +++ b/app/src/assets/svg/icon-swap.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/components/DraggableChat/useDraggableFAB.ts b/app/src/components/DraggableChat/useDraggableFAB.ts deleted file mode 100644 index 8fc96384..00000000 --- a/app/src/components/DraggableChat/useDraggableFAB.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2023-2025 dev.mimir authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import { type Position, useDraggableSquare } from '@mimir-wallet/ui'; - -interface UseDraggableFABOptions { - initialPosition?: Position; - size?: number; - margin?: number; -} - -const FAB_SIZE = 60; -const FAB_MARGIN = 8; - -/** - * @deprecated Use useDraggableSquare from @mimir-wallet/ui instead - * This hook is kept for backward compatibility and will be removed in a future version - */ -export function useDraggableFAB({ - initialPosition, - size = FAB_SIZE, - margin = FAB_MARGIN, -}: UseDraggableFABOptions = {}) { - return useDraggableSquare({ - initialPosition, - size, - margin, - }); -} diff --git a/app/src/components/InputNetworkToken/InputNetworkTokenContext.tsx b/app/src/components/InputNetworkToken/InputNetworkTokenContext.tsx index 534325aa..384dd53f 100644 --- a/app/src/components/InputNetworkToken/InputNetworkTokenContext.tsx +++ b/app/src/components/InputNetworkToken/InputNetworkTokenContext.tsx @@ -1,7 +1,7 @@ // Copyright 2023-2025 dev.mimir authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { TokenNetworkItem } from './types'; +import type { TokenNetworkItem, TokenNetworkValue } from './types'; import type { InputNetworkTokenContextValue } from './useInputNetworkTokenContext'; import { useChain, useChains, useNetwork } from '@mimir-wallet/polkadot-core'; @@ -23,11 +23,21 @@ export interface InputNetworkTokenProviderProps { /** Account address for fetching balance data */ address?: string; - /** Default network (used when network prop is not provided) */ + /** Default network (used in uncontrolled mode) */ defaultNetwork?: string; - /** Default identifier */ + /** Default identifier (used in uncontrolled mode) */ defaultIdentifier?: string; + /** + * Controlled value - if provided, component becomes controlled + * When controlled, internal state syncs with this value + */ + value?: TokenNetworkValue; + /** + * Callback when value changes (works in both controlled and uncontrolled modes) + */ + onChange?: (value: TokenNetworkValue) => void; + /** Default keep alive setting */ defaultKeepAlive?: boolean; @@ -37,6 +47,8 @@ export interface InputNetworkTokenProviderProps { xcmOnly?: boolean; /** Custom filter function for tokens */ tokenFilter?: (item: TokenNetworkItem) => boolean; + /** Include all assets without balance query (for destination token selection) */ + includeAllAssets?: boolean; } /** @@ -60,24 +72,30 @@ export function InputNetworkTokenProvider({ children, address, defaultNetwork, - defaultIdentifier = 'native', + defaultIdentifier, + value: controlledValue, + onChange, defaultKeepAlive = true, supportedNetworks, xcmOnly = false, tokenFilter, + includeAllAssets = false, }: InputNetworkTokenProviderProps) { // Get initial network from context const { network: initialNetwork } = useNetwork(); const { enableNetwork } = useChains(); - // Store user's preferred selection (may become invalid if constraints change) - const [preferredNetwork, setPreferredNetwork] = useState( + // Internal state for uncontrolled mode + const [internalNetwork, setInternalNetwork] = useState( defaultNetwork || initialNetwork, ); + const [internalIdentifier, setInternalIdentifier] = useState< + string | undefined + >(defaultIdentifier); - // Preferred identifier state - const [preferredIdentifier, setPreferredIdentifier] = - useState(defaultIdentifier); + // Derive effective preferred values: use controlled value if provided, otherwise internal state + const preferredNetwork = controlledValue?.network ?? internalNetwork; + const preferredIdentifier = controlledValue?.identifier ?? internalIdentifier; // Keep alive state const [keepAlive, setKeepAlive] = useState(defaultKeepAlive); @@ -129,6 +147,7 @@ export function InputNetworkTokenProvider({ supportedNetworks: supportedNetworks, xcmOnly, tokenFilter, + includeAllAssets, }, ); @@ -142,6 +161,11 @@ export function InputNetworkTokenProvider({ // Compute selected token from items, with fallback to direct balance query const token = useMemo(() => { + // If no identifier selected, no token + if (!identifier) { + return undefined; + } + // First try to find in items list const fromItems = findItemByValue(items, { network, identifier }); @@ -172,17 +196,17 @@ export function InputNetworkTokenProvider({ return { network, identifier }; }, [network, identifier]); - // Setters + // Setters - update internal state only (onChange is called in setValue) const setNetwork = useCallback( (newNetwork: string) => { - setPreferredNetwork(newNetwork); + setInternalNetwork(newNetwork); addRecent(newNetwork); }, [addRecent], ); const setIdentifier = useCallback((newIdentifier: string) => { - setPreferredIdentifier(newIdentifier); + setInternalIdentifier(newIdentifier); }, []); // Default maxVisibleNetworks is 5 (same as InputNetworkToken default) @@ -199,20 +223,27 @@ export function InputNetworkTokenProvider({ [addRecent, enableNetwork], ); + // Main setter - updates internal state and notifies onChange const setValue = useCallback( (newValue: { network: string; identifier: string }) => { - if (newValue.network !== network) { + // Update internal state + if (newValue.network !== internalNetwork) { setNetwork(newValue.network); } - setIdentifier(newValue.identifier); + if (newValue.identifier !== internalIdentifier) { + setIdentifier(newValue.identifier); + } + + // Notify onChange (single call with complete value) + onChange?.(newValue); }, - [network, setNetwork, setIdentifier], + [internalNetwork, internalIdentifier, setNetwork, setIdentifier, onChange], ); // Reset to initial state const reset = useCallback(() => { - setPreferredIdentifier(defaultIdentifier); + setInternalIdentifier(defaultIdentifier); setKeepAlive(defaultKeepAlive); }, [defaultIdentifier, defaultKeepAlive]); diff --git a/app/src/components/InputNetworkToken/index.ts b/app/src/components/InputNetworkToken/index.ts index 5da33f22..a6987519 100644 --- a/app/src/components/InputNetworkToken/index.ts +++ b/app/src/components/InputNetworkToken/index.ts @@ -16,3 +16,6 @@ export { useTokenSelect, } from './useInputNetworkTokenContext'; export { useTokenNetworkData } from './useTokenNetworkData'; +export { findItemByValue, itemToValue } from './utils'; +export { default as NetworkTokenSelector } from './NetworkTokenSelector'; +export { default as NetworkTokenTrigger } from './NetworkTokenTrigger'; diff --git a/app/src/components/InputNetworkToken/useInputNetworkTokenContext.ts b/app/src/components/InputNetworkToken/useInputNetworkTokenContext.ts index 32da3ef3..fe3b0230 100644 --- a/app/src/components/InputNetworkToken/useInputNetworkTokenContext.ts +++ b/app/src/components/InputNetworkToken/useInputNetworkTokenContext.ts @@ -18,7 +18,7 @@ export interface InputNetworkTokenContextValue { // Core state value: TokenNetworkValue | undefined; network: string; - identifier: string; + identifier: string | undefined; // Fetched data (from useTokenNetworkData) token: TokenNetworkItem | undefined; diff --git a/app/src/components/InputNetworkToken/useTokenNetworkData.ts b/app/src/components/InputNetworkToken/useTokenNetworkData.ts index f68bf90e..c4e37981 100644 --- a/app/src/components/InputNetworkToken/useTokenNetworkData.ts +++ b/app/src/components/InputNetworkToken/useTokenNetworkData.ts @@ -10,7 +10,7 @@ import { useMemo } from 'react'; import { calculateUsdValue, createTokenNetworkKey, - sortByUsdValue, + sortWithBalancePriority, } from './utils'; import { @@ -28,6 +28,8 @@ interface UseTokenNetworkDataOptions { xcmOnly?: boolean; /** Custom filter function for tokens */ tokenFilter?: (item: TokenNetworkItem) => boolean; + /** Include all assets without balance query (for destination token selection) */ + includeAllAssets?: boolean; } /** @@ -50,18 +52,20 @@ export function useTokenNetworkData( activeNetworkFilter, xcmOnly = false, tokenFilter, + includeAllAssets = false, } = options || {}; // Use single chain query when user filters by a specific network const [filteredChainData, filteredChainFetched] = useChainBalances( activeNetworkFilter ?? undefined, address, - { alwaysIncludeNative: true }, + { alwaysIncludeNative: true, includeAllAssets }, ); // Multi network: use useAllChainBalances (always enabled for "All" filter) const allChainBalances = useAllChainBalances(address, { alwaysIncludeNative: true, + includeAllAssets, }); // Get network configurations @@ -203,7 +207,7 @@ export function useTokenNetworkData( } return { - items: sortByUsdValue(filteredItems), + items: sortWithBalancePriority(filteredItems), allFetched: filteredChainFetched, }; } @@ -268,7 +272,7 @@ export function useTokenNetworkData( } return { - items: sortByUsdValue(filteredItems), + items: sortWithBalancePriority(filteredItems), allFetched: allDone, }; }, [ diff --git a/app/src/components/InputNetworkToken/utils.ts b/app/src/components/InputNetworkToken/utils.ts index d3f88cf3..59298f80 100644 --- a/app/src/components/InputNetworkToken/utils.ts +++ b/app/src/components/InputNetworkToken/utils.ts @@ -104,22 +104,32 @@ export function filterByNetwork( } /** - * Sort items by USD value (descending), then by symbol + * Sort items with balance priority + * Tokens with balance first, then by USD value, then by price availability, finally by symbol * @param items - Token network items to sort * @returns Sorted items (new array) */ -export function sortByUsdValue(items: TokenNetworkItem[]): TokenNetworkItem[] { +export function sortWithBalancePriority( + items: TokenNetworkItem[], +): TokenNetworkItem[] { return [...items].sort((a, b) => { - // First sort by USD value descending - if (b.usdValue !== a.usdValue) { - return b.usdValue - a.usdValue; - } + // Tokens with balance first + const aHasBalance = a.token.transferrable > 0n; + const bHasBalance = b.token.transferrable > 0n; + + if (aHasBalance !== bHasBalance) return bHasBalance ? 1 : -1; + + // Then by USD value + if (a.usdValue !== b.usdValue) return b.usdValue - a.usdValue; + + // Then by price availability + const aHasPrice = a.token.price && a.token.price > 0; + const bHasPrice = b.token.price && b.token.price > 0; - // Then sort by symbol alphabetically - const symbolA = a.token.symbol?.toLowerCase() || ''; - const symbolB = b.token.symbol?.toLowerCase() || ''; + if (aHasPrice !== bHasPrice) return bHasPrice ? 1 : -1; - return symbolA.localeCompare(symbolB); + // Finally by symbol + return (a.token.symbol || '').localeCompare(b.token.symbol || ''); }); } diff --git a/app/src/config/dapp.ts b/app/src/config/dapp.ts index b26d32b3..b8a6c9bd 100644 --- a/app/src/config/dapp.ts +++ b/app/src/config/dapp.ts @@ -363,6 +363,19 @@ export const dapps: DappOption URL>[] = [ twitter: 'https://x.com/Mimir_global/', tags: ['XCM', 'Transfer', 'CrossChain'], }, + { + id: 8, + name: 'Cross-chain Swap', + description: + 'Swap tokens across different chains in the Polkadot ecosystem', + url: 'mimir://app/cross-chain-swap', + icon: '/dapp-icons/cross-chain-swap.svg', + supportedChains: true, + website: 'https://mimir.global/', + github: 'https://github.com/mimir-labs/', + twitter: 'https://x.com/Mimir_global/', + tags: ['XCM', 'Swap', 'CrossChain', 'DeFi'], + }, { id: 500, icon: LogoCircle, diff --git a/app/src/constants.ts b/app/src/constants.ts index 062aab72..14db4d66 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -74,3 +74,5 @@ export const COOKIE_CONSENT_STORAGE_KEY = `${LS_NAMESPACE}cookie_consent`; export const SUGGESTIONS_DISMISSED_KEY = `${LS_NAMESPACE}suggestions_dismissed`; export const HERO_TRANSFER_TYPE_KEY = `${LS_NAMESPACE}hero_transfer_type`; + +export const CROSS_CHAIN_SWAP_SLIPPAGE_KEY = `${LS_NAMESPACE}cross_chain_swap_slippage`; diff --git a/app/src/containers/sidebar/components/NavigationMenu.tsx b/app/src/containers/sidebar/components/NavigationMenu.tsx index 12da74ea..9232baa8 100644 --- a/app/src/containers/sidebar/components/NavigationMenu.tsx +++ b/app/src/containers/sidebar/components/NavigationMenu.tsx @@ -11,6 +11,7 @@ import IconAssets from '@/assets/svg/icon-assets.svg?react'; import IconDapp from '@/assets/svg/icon-dapp.svg?react'; import IconHome from '@/assets/svg/icon-home.svg?react'; import IconSetting from '@/assets/svg/icon-set.svg?react'; +import IconSwap from '@/assets/svg/icon-swap.svg?react'; import IconTransaction from '@/assets/svg/icon-transaction.svg?react'; import { useMultiChainTransactionCounts } from '@/hooks/useTransactions'; @@ -49,7 +50,7 @@ function NavLink({ size="lg" radius="md" variant="light" - className="text-foreground/50 aria-[current=page]:bg-secondary aria-[current=page]:text-primary hover:bg-secondary hover:text-primary h-[50px] items-center justify-start gap-x-2.5 px-[15px] py-[20px] text-base font-semibold" + className="text-foreground/50 aria-[current=page]:bg-secondary aria-[current=page]:text-primary hover:bg-secondary hover:text-primary h-[50px] items-center justify-start gap-x-2.5 px-[15px] py-5 text-base font-semibold" > + +
{totalCounts}
) : null diff --git a/app/src/features/shared/ErrorDisplay.tsx b/app/src/features/shared/ErrorDisplay.tsx index 29fe1500..f73e6917 100644 --- a/app/src/features/shared/ErrorDisplay.tsx +++ b/app/src/features/shared/ErrorDisplay.tsx @@ -51,30 +51,3 @@ export function ErrorDisplay({ ); } - -/** - * Legacy error display component for backwards compatibility - * @deprecated Use ErrorDisplay component instead - */ -export function LegacyErrorDisplay({ - error, - className = 'bg-secondary rounded-[10px] p-2.5 break-all', -}: { - error: Error | null; - className?: string; -}) { - if (!error) return null; - - return ( -
-
- {error.message} -
-
- ); -} diff --git a/app/src/features/shared/index.ts b/app/src/features/shared/index.ts index 7b3bf512..bc913596 100644 --- a/app/src/features/shared/index.ts +++ b/app/src/features/shared/index.ts @@ -14,7 +14,7 @@ export { } from './error-handling'; // Error display components -export { ErrorDisplay, LegacyErrorDisplay } from './ErrorDisplay'; +export { ErrorDisplay } from './ErrorDisplay'; // Call data utilities export type { CallSummary } from './calldata-utils'; diff --git a/app/src/features/template/utils.ts b/app/src/features/template/utils.ts index ce0258f7..186058bf 100644 --- a/app/src/features/template/utils.ts +++ b/app/src/features/template/utils.ts @@ -23,22 +23,3 @@ export function decodeCallSection( return [call.section, call.method]; } - -/** - * Legacy function for backwards compatibility - * @deprecated Use decodeCallSection with shared utilities - */ -export function decodeCallSectionLegacy( - registry: Registry, - callData: string, -): [string, string] | undefined { - if (!callData) return undefined; - - try { - const call = registry.createType('Call', callData); - - return [call.section, call.method]; - } catch { - return undefined; - } -} diff --git a/app/src/hooks/useChainBalances.ts b/app/src/hooks/useChainBalances.ts index b53d6e40..b69ca7b8 100644 --- a/app/src/hooks/useChainBalances.ts +++ b/app/src/hooks/useChainBalances.ts @@ -1,6 +1,7 @@ // Copyright 2023-2025 dev.mimir authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { CompleteEnhancedAssetInfo } from '@mimir-wallet/service'; import type { HexString } from '@polkadot/util/types'; import { @@ -17,8 +18,50 @@ import { useMemo } from 'react'; import { useAllXcmAsset, useXcmAsset } from './useXcmAssets'; +/** + * Merge balance query results with all XCM assets + * Assets without balance will have zero balance fields + */ +function mergeBalancesWithAllAssets( + balanceData: AccountEnhancedAssetBalance[], + allAssets: CompleteEnhancedAssetInfo[], + addressHex: string, +): AccountEnhancedAssetBalance[] { + // Build a map of assets with balance for quick lookup + const balanceMap = new Map(); + + for (const item of balanceData) { + const identifier = item.isNative ? 'native' : item.key; + + balanceMap.set(identifier, item); + } + + // Merge all assets with balance data + return allAssets.map((asset) => { + const identifier = asset.isNative ? 'native' : asset.key; + const withBalance = balanceMap.get(identifier); + + if (withBalance) { + return withBalance; + } + + // Return asset with zero balance + return { + ...asset, + address: addressHex as `0x${string}`, + total: 0n, + locked: 0n, + reserved: 0n, + free: 0n, + transferrable: 0n, + }; + }); +} + type ChainBalancesOptions = { alwaysIncludeNative?: boolean; + /** Include all assets with zero balance (for token selection where user doesn't have the token yet) */ + includeAllAssets?: boolean; }; /** @@ -60,18 +103,37 @@ export function useChainBalances( }, [allXcmAssets, chain, options?.alwaysIncludeNative]); const { data, isFetched, isFetching } = useQuery({ + // Include includeAllAssets in query key to differentiate cache // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: ['chain-balances', chain, addressHex, false] as const, + queryKey: [ + 'chain-balances', + chain, + addressHex, + !!options?.includeAllAssets, + ] as const, queryFn: async ({ - queryKey: [, chain, addressHex], + queryKey: [, chain, addressHex, includeAllAssets], }): Promise => { if (!chain || !addressHex || !chainAssets.length) { throw new Error('Chain, address, and chain assets are required'); } + // Get all assets for the chain (for includeAllAssets merge) + const allChainAssets = allXcmAssets?.[chain || ''] || []; + const api = await ApiManager.getInstance().getApi(chain); + const balances = await fetchAccountBalances( + api, + addressHex as HexString, + chainAssets, + ); + + // Merge with all assets if includeAllAssets is enabled + if (includeAllAssets && allChainAssets.length > 0) { + return mergeBalancesWithAllAssets(balances, allChainAssets, addressHex); + } - return fetchAccountBalances(api, addressHex as HexString, chainAssets); + return balances; }, enabled: !!address && !!chain && isXcmAssetsFetched && chainAssets.length > 0, @@ -99,6 +161,8 @@ type UseAllChainBalances = { type AllChainBalancesOptions = { onlyWithPrice?: boolean; alwaysIncludeNative?: boolean; + /** Include all assets with zero balance (for token selection where user doesn't have the token yet) */ + includeAllAssets?: boolean; }; /** @@ -131,9 +195,10 @@ export function useAllChainBalances( () => enabledChains.map((chain) => { const chainName = chain.key; - let chainAssets = allXcmAssets?.[chainName]; + const allChainAssets = allXcmAssets?.[chainName] || []; + let chainAssets = allChainAssets; - if (chainAssets) { + if (chainAssets.length > 0) { // Find native asset for potential inclusion const nativeAsset = chainAssets.find((asset) => asset.isNative); @@ -161,13 +226,15 @@ export function useAllChainBalances( } const finalChainAssets = chainAssets; + const includeAllAssets = options?.includeAllAssets; return { + // Include includeAllAssets in query key to differentiate cache queryKey: [ 'chain-balances', chainName, addressHex, - !!options?.onlyWithPrice, + !!includeAllAssets, ] as const, staleTime: 12_000, refetchInterval: 12_000, @@ -177,7 +244,6 @@ export function useAllChainBalances( !!address && !!isXcmAssetsFetched && !!allXcmAssets && - !!finalChainAssets && finalChainAssets.length > 0, queryFn: async (): Promise => { if (!addressHex || !finalChainAssets?.length) { @@ -185,12 +251,22 @@ export function useAllChainBalances( } const api = await apiManager.getApi(chainName); - - return fetchAccountBalances( + const balances = await fetchAccountBalances( api, addressHex as HexString, finalChainAssets, ); + + // Merge with all assets if includeAllAssets is enabled + if (includeAllAssets && allChainAssets.length > 0) { + return mergeBalancesWithAllAssets( + balances, + allChainAssets, + addressHex, + ); + } + + return balances; }, }; }), @@ -202,6 +278,7 @@ export function useAllChainBalances( address, options?.onlyWithPrice, options?.alwaysIncludeNative, + options?.includeAllAssets, apiManager, ], ); diff --git a/app/src/hooks/useXcmAssets.ts b/app/src/hooks/useXcmAssets.ts index e3a03165..aac1ebd4 100644 --- a/app/src/hooks/useXcmAssets.ts +++ b/app/src/hooks/useXcmAssets.ts @@ -22,6 +22,7 @@ import type { HexString } from '@polkadot/util/types'; import { allEndpoints, getAssetLocation, + getAssets, getChainIcon, useChain, } from '@mimir-wallet/polkadot-core'; @@ -99,6 +100,10 @@ function enhanceAsset( // Invalid location format - mark as non-XCM asset normalizedLocation = undefined; } + } else if (asset.isNative) { + normalizedLocation = getAssets(paraspellChain as any).find( + (item) => item.isNative, + )?.location; } return { diff --git a/app/src/pages/explorer/index.tsx b/app/src/pages/explorer/index.tsx index 91f01fcb..642dbddc 100644 --- a/app/src/pages/explorer/index.tsx +++ b/app/src/pages/explorer/index.tsx @@ -8,6 +8,7 @@ import React, { useMemo } from 'react'; import AppFrame from './AppFrame'; +import CrossChainSwap from '@/apps/cross-chain-swap'; import CrossChainTransfer from '@/apps/cross-chain-transfer'; import MultiTransfer from '@/apps/multi-transfer'; import SubmitCalldata from '@/apps/submit-calldata'; @@ -120,6 +121,8 @@ function AppExplorer() {
+ ) : url.startsWith('mimir://app/cross-chain-swap') ? ( + ) : (
{element}
); diff --git a/package.json b/package.json index 28a5e6a9..32f06b0c 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,11 @@ "tsx": "^4.21.0", "turbo": "^2.6.3", "typescript": "^5.9.3" + }, + "pnpm": { + "overrides": { + "react": "19.2.3", + "react-dom": "^19.2.3" + } } } diff --git a/packages/polkadot-core/README.md b/packages/polkadot-core/README.md index 1b6357bb..e99f4b03 100644 --- a/packages/polkadot-core/README.md +++ b/packages/polkadot-core/README.md @@ -1,39 +1,16 @@ # @mimir-wallet/polkadot-core -The core Polkadot blockchain integration package for Mimir Wallet, providing essential APIs, utilities, and React hooks for interacting with Polkadot and substrate-based chains. - -## Overview - -This package serves as the foundational layer for Polkadot ecosystem integration within Mimir Wallet. It abstracts the complexity of blockchain interactions while providing a robust, type-safe interface for building multisig wallet applications. +Core blockchain integration package for Mimir Wallet, providing APIs and React hooks for Polkadot and Substrate-based chains. ## Features -### πŸ”— API Management -- **Multi-chain API initialization** - Support for Polkadot, Kusama, and all substrate-based chains -- **Connection pooling** - Efficient management of multiple network connections -- **Automatic reconnection** - Resilient connections with automatic retry logic -- **RPC endpoint management** - Configurable RPC endpoints with failover support - -### πŸ”§ Transaction Processing -- **Call filtering** - Advanced transaction call filtering and validation -- **Dry-run capabilities** - Simulate transactions before execution -- **Fee estimation** - Accurate transaction fee calculation -- **Batch operations** - Support for batch transaction processing - -### 🎯 Simulation & Testing -- **Chopsticks integration** - Fork simulation for testing -- **Balance change analysis** - Detailed balance impact analysis -- **Event parsing** - Comprehensive transaction event handling - -### βš›οΈ React Integration -- **Custom hooks** - Purpose-built React hooks for common operations -- **Context providers** - Clean state management with React context -- **Type safety** - Full TypeScript support with auto-generated types +- **Multi-chain API Management** - Connection pooling with automatic reconnection +- **Transaction Processing** - Call filtering, fee estimation, batch operations +- **Dry-run Simulation** - Chopsticks integration for transaction preview +- **React Hooks** - `useChains`, `useChainStatus`, `useApiStore` and more ## Installation -This package is part of the Mimir Wallet monorepo and is typically not installed separately. However, if you need to use it in your own project: - ```bash pnpm add @mimir-wallet/polkadot-core ``` @@ -41,155 +18,58 @@ pnpm add @mimir-wallet/polkadot-core ### Peer Dependencies ```bash -pnpm add react react-dom zustand @polkadot/api @polkadot/types @polkadot/util +pnpm add react zustand @polkadot/api @polkadot/types ``` ## Usage -### Basic Setup - ```typescript -import { initializeApi, ApiRoot } from '@mimir-wallet/polkadot-core'; +import { + NetworkProvider, + useChains, + useApiStore, + initializeApiStore, +} from '@mimir-wallet/polkadot-core'; -// Initialize API for a specific network -const api = await initializeApi('polkadot'); +// Initialize API store +await initializeApiStore(); -// Use in React components +// Wrap your app with NetworkProvider function App() { return ( - - - + + + ); } -``` - -### Using React Hooks - -```typescript -import { useApi, useAllApis, useNetworks } from '@mimir-wallet/polkadot-core'; +// Use hooks in components function WalletComponent() { - const api = useApi(); // Get current API instance - const allApis = useAllApis(); // Get all connected APIs - const networks = useNetworks(); // Get available networks + const { chains } = useChains(); + const { getApi } = useApiStore(); - // Your component logic + // Get API for a specific chain + const api = getApi('polkadot'); + const tx = api?.tx.balances.transferKeepAlive(recipient, amount); } ``` -### Transaction Calls - -```typescript -import { createCall, buildTx } from '@mimir-wallet/polkadot-core'; - -// Create a transaction call -const call = createCall(api, 'balances', 'transfer', [recipient, amount]); - -// Build and submit transaction -const tx = await buildTx(api, call, signer); -``` - ### Dry-run Simulation ```typescript import { dryRun, parseBalancesChange } from '@mimir-wallet/polkadot-core'; -// Simulate transaction execution const result = await dryRun(api, tx, signer); const balanceChanges = parseBalancesChange(result); ``` -## API Reference - -### Core Functions - -#### `initializeApi(network: string): Promise` -Initialize API connection to a specific network. - -#### `createApi(endpoint: string): Promise` -Create API instance for custom endpoint. - -### React Hooks - -#### `useApi(): ApiPromise | null` -Get the current API instance for the active network. - -#### `useAllApis(): Record` -Get all initialized API instances mapped by network name. - -#### `useNetworks(): Network[]` -Get list of all available networks and their configurations. - -#### `useIdentityApi(): ApiPromise | null` -Get API instance specifically for identity operations. - -### Utilities - -#### `encodeAddress(address: string, ss58Format?: number): string` -Encode address to specific SS58 format. - -#### `decodeAddress(address: string): Uint8Array` -Decode SS58 address to raw bytes. - -## Configuration - -### Network Configuration - -Networks are configured in the `config.ts` file: - -```typescript -export const networks: Network[] = [ - { - id: 'polkadot', - name: 'Polkadot', - endpoints: ['wss://rpc.polkadot.io'], - ss58Format: 0, - // ... other config - } -]; -``` - -### Custom Endpoints - -You can add custom RPC endpoints: - -```typescript -import { allEndpoints } from '@mimir-wallet/polkadot-core'; - -// Add custom endpoint -allEndpoints.push({ - name: 'Custom Network', - url: 'wss://custom-rpc.example.com' -}); -``` - -## Architecture +## Testing +```bash +pnpm test # Unit tests +pnpm test:integration # Integration tests (Paseo testnet) ``` -@mimir-wallet/polkadot-core/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ api/ # API management -β”‚ β”œβ”€β”€ call/ # Transaction calls -β”‚ β”œβ”€β”€ dry-run/ # Simulation functionality -β”‚ β”œβ”€β”€ simulate/ # Advanced simulation -β”‚ β”œβ”€β”€ hooks/ # React hooks -β”‚ β”œβ”€β”€ config/ # Network configurations -β”‚ └── utils/ # Utility functions -``` - -## TypeScript Support - -This package includes comprehensive TypeScript definitions: - -- Auto-generated types from Polkadot API -- Custom type augmentations for substrate chains -- Full type safety for all operations - -## Contributing - -This package is part of the Mimir Wallet monorepo. Please see the main [Contributing Guide](../../README.md#contributing) for details. ## License -Licensed under the Apache License, Version 2.0. See [LICENSE](../../LICENSE) for details. \ No newline at end of file +[Apache License 2.0](../../LICENSE) diff --git a/packages/polkadot-core/package.json b/packages/polkadot-core/package.json index a333197a..404b6501 100644 --- a/packages/polkadot-core/package.json +++ b/packages/polkadot-core/package.json @@ -40,9 +40,9 @@ }, "dependencies": { "@acala-network/chopsticks-core": "^1.2.5", - "@paraspell/assets": "^12.0.3", - "@paraspell/sdk-common": "^12.0.3", - "@paraspell/sdk-core": "^12.0.3", + "@paraspell/assets": "^12.1.1", + "@paraspell/sdk-common": "^12.1.1", + "@paraspell/sdk-core": "^12.1.1", "@polkadot/api": "^16.5.4", "@polkadot/api-augment": "^16.5.4", "@polkadot/api-base": "^16.5.4", @@ -60,7 +60,8 @@ "eventemitter3": "^5.0.1", "idb": "^8.0.2", "jsondiffpatch": "^0.6.0", - "lodash-es": "^4.17.21" + "lodash-es": "^4.17.21", + "polkadot-api": "^1.23.1" }, "devDependencies": { "@mimir-wallet/dev": "workspace:^", diff --git a/packages/polkadot-core/src/api/ApiManager.ts b/packages/polkadot-core/src/api/ApiManager.ts index 2ce03741..f5d20ac6 100644 --- a/packages/polkadot-core/src/api/ApiManager.ts +++ b/packages/polkadot-core/src/api/ApiManager.ts @@ -8,16 +8,20 @@ import type { Endpoint, } from '../types/types.js'; import type { HexString } from '@polkadot/util/types'; +import type { PolkadotClient } from 'polkadot-api'; import { ApiPromise } from '@polkadot/api'; import { deriveMapCache, setDeriveCache } from '@polkadot/api-derive/util'; import { isHex } from '@polkadot/util'; +import { createClient } from 'polkadot-api'; +import { withPolkadotSdkCompat } from 'polkadot-api/polkadot-sdk-compat'; import { DEFAULT_AUX } from '../utils/defaults.js'; import { saveMetadata } from '../utils/metadata.js'; -import { createApi } from './api-factory.js'; +import { clearProvider, createApi, getProvider } from './api-factory.js'; import { getIdentityNetwork, resolveChain } from './chain-resolver.js'; +import { PapiProviderAdapter } from './PapiProviderAdapter.js'; /** * Singleton class for managing all blockchain API connections @@ -35,6 +39,10 @@ export class ApiManager { private chainListeners: Map void>> = new Map(); + // papi support: stores PolkadotClient instances and adapters + private papiClients: Map = new Map(); + private papiAdapters: Map = new Map(); + private constructor() {} /** @@ -314,6 +322,8 @@ export class ApiManager { return; } + this._cleanupPapiResources(network); + const connection = this.apis.get(network); if (connection?.api) { @@ -333,6 +343,8 @@ export class ApiManager { // Clear all references this.references.delete(network); + this._cleanupPapiResources(network); + const connection = this.apis.get(network); if (connection?.api) { @@ -344,6 +356,24 @@ export class ApiManager { this.initPromises.delete(network); } + /** + * Cleanup papi resources for a network + */ + private _cleanupPapiResources(network: string): void { + // Destroy papi client + const papiClient = this.papiClients.get(network); + + if (papiClient) { + papiClient.destroy(); + this.papiClients.delete(network); + } + + this.papiAdapters.delete(network); + + // Clear provider reference + clearProvider(network); + } + /** * Reconnect to a chain */ @@ -537,4 +567,80 @@ export class ApiManager { return this.getApi(identityNetwork); } + + /** + * Get papi PolkadotClient by network key or genesis hash + * Lazily creates the client on first request, sharing the WebSocket with ApiPromise + * + * @param networkOrGenesisHash - Network key or genesis hash + * @returns Promise resolving to PolkadotClient + */ + async getPapiClient(networkOrGenesisHash: string): Promise { + // Resolve network identifier + const network = this._resolveNetwork(networkOrGenesisHash); + + if (!network) { + throw new Error(`Network not found: ${networkOrGenesisHash}`); + } + + // Check for existing client + const existing = this.papiClients.get(network); + + if (existing) return existing; + + // Ensure ApiPromise is initialized first (this creates the ApiProvider) + await this.getApi(network); + + // Get ApiProvider and create adapter + const provider = getProvider(network); + + if (!provider) { + throw new Error(`ApiProvider not found for: ${network}`); + } + + const adapter = new PapiProviderAdapter(provider); + + this.papiAdapters.set(network, adapter); + + // Create papi client with SDK compatibility layer + const client = createClient( + withPolkadotSdkCompat(adapter.getJsonRpcProvider()), + ); + + this.papiClients.set(network, client); + + return client; + } + + /** + * Get both @polkadot/api and papi clients for a network + * Convenient method when you need to use both APIs + * + * @param networkOrGenesisHash - Network key or genesis hash + * @returns Promise resolving to both ApiPromise and PolkadotClient + */ + async getDualApi(networkOrGenesisHash: string): Promise<{ + api: ApiPromise; + papiClient: PolkadotClient; + }> { + const api = await this.getApi(networkOrGenesisHash); + const papiClient = await this.getPapiClient(networkOrGenesisHash); + + return { api, papiClient }; + } + + /** + * Resolve network identifier to network key + */ + private _resolveNetwork(networkOrGenesisHash: string): string | undefined { + // Direct match + if (this.apis.has(networkOrGenesisHash)) { + return networkOrGenesisHash; + } + + // Lookup by genesis hash + const connection = this._resolveConnection(networkOrGenesisHash); + + return connection?.network; + } } diff --git a/packages/polkadot-core/src/api/ApiPromise$.ts b/packages/polkadot-core/src/api/ApiPromise$.ts new file mode 100644 index 00000000..f5f137ab --- /dev/null +++ b/packages/polkadot-core/src/api/ApiPromise$.ts @@ -0,0 +1,10 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { ApiPromise } from '@polkadot/api'; + +export class ApiPromise$ extends ApiPromise { + get provider() { + return this._rpcCore.provider; + } +} diff --git a/packages/polkadot-core/src/api/PapiProviderAdapter.ts b/packages/polkadot-core/src/api/PapiProviderAdapter.ts new file mode 100644 index 00000000..eece4fe7 --- /dev/null +++ b/packages/polkadot-core/src/api/PapiProviderAdapter.ts @@ -0,0 +1,193 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ApiProvider } from './ApiProvider.js'; + +/** + * papi JsonRpcConnection interface + */ +export interface JsonRpcConnection { + send: (message: string) => void; + disconnect: () => void; +} + +/** + * papi JsonRpcProvider interface + * A function that takes a message handler and returns a connection object + */ +export type JsonRpcProvider = ( + onMessage: (message: string) => void, +) => JsonRpcConnection; + +/** + * Adapter to bridge ApiProvider to papi's JsonRpcProvider interface. + * Enables sharing the same WebSocket connection between @polkadot/api and polkadot-api. + * + * @example + * ```typescript + * const provider = new ApiProvider(endpoints); + * const adapter = new PapiProviderAdapter(provider); + * const client = createClient(withPolkadotSdkCompat(adapter.getJsonRpcProvider())); + * ``` + */ +export class PapiProviderAdapter { + readonly #provider: ApiProvider; + #messageHandler: ((message: string) => void) | null = null; + + constructor(provider: ApiProvider) { + this.#provider = provider; + } + + /** + * Returns a papi-compatible JsonRpcProvider function + */ + getJsonRpcProvider(): JsonRpcProvider { + return (onMessage: (message: string) => void): JsonRpcConnection => { + this.#messageHandler = onMessage; + + return { + send: (message: string) => this.#handleSend(message), + disconnect: () => this.#handleDisconnect(), + }; + }; + } + + /** + * Handle outgoing JSON-RPC messages from papi. + * Parses the message, calls ApiProvider methods, and sends responses back. + */ + #handleSend(message: string): void { + const parsed = JSON.parse(message) as { + id: number; + method: string; + params: unknown[]; + }; + const { id, method, params } = parsed; + + // Handle different types of requests + if (method.includes('subscribe') && !method.includes('unsubscribe')) { + this.#handleSubscription(id, method, params).catch((error) => { + this.#sendError(id, (error as Error).message); + }); + } else if (method.includes('unsubscribe')) { + this.#handleUnsubscribe(id, method, params).catch((error) => { + this.#sendError(id, (error as Error).message); + }); + } else { + this.#handleRequest(id, method, params).catch((error) => { + this.#sendError(id, (error as Error).message); + }); + } + } + + /** + * Handle regular RPC requests + */ + async #handleRequest( + id: number, + method: string, + params: unknown[], + ): Promise { + const result = await this.#provider.send(method, params); + + this.#sendResult(id, result); + } + + /** + * Handle subscription requests + */ + async #handleSubscription( + id: number, + method: string, + params: unknown[], + ): Promise { + // Derive subscription type (e.g., chain_subscribeNewHead -> chain_newHead) + const subscriptionType = method.replace('_subscribe', '_'); + + const subId = await this.#provider.subscribe( + subscriptionType, + method, + params, + (error, result) => { + if (error) { + console.error('[PapiProviderAdapter] Subscription error:', error); + + return; + } + + // Forward subscription notifications to papi + this.#sendNotification(subscriptionType, subId, result); + }, + ); + + // Send subscription confirmation + this.#sendResult(id, subId); + } + + /** + * Handle unsubscribe requests + */ + async #handleUnsubscribe( + id: number, + method: string, + params: unknown[], + ): Promise { + const subscriptionType = method.replace('_unsubscribe', '_'); + const [subId] = params as [string | number]; + + const result = await this.#provider.unsubscribe( + subscriptionType, + method, + subId, + ); + + this.#sendResult(id, result); + } + + /** + * Send a successful result back to papi + */ + #sendResult(id: number, result: unknown): void { + const response = JSON.stringify({ jsonrpc: '2.0', id, result }); + + this.#messageHandler?.(response); + } + + /** + * Send an error response back to papi + */ + #sendError(id: number, message: string): void { + const response = JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32603, message }, + }); + + this.#messageHandler?.(response); + } + + /** + * Send a subscription notification to papi + */ + #sendNotification( + method: string, + subscription: string | number, + result: unknown, + ): void { + const notification = JSON.stringify({ + jsonrpc: '2.0', + method, + params: { subscription, result }, + }); + + this.#messageHandler?.(notification); + } + + /** + * Handle disconnect from papi. + * Cleans up handlers but does NOT disconnect the shared WebSocket. + */ + #handleDisconnect(): void { + this.#messageHandler = null; + } +} diff --git a/packages/polkadot-core/src/api/api-factory.ts b/packages/polkadot-core/src/api/api-factory.ts index 772c4524..ee948f57 100644 --- a/packages/polkadot-core/src/api/api-factory.ts +++ b/packages/polkadot-core/src/api/api-factory.ts @@ -2,16 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import type { Endpoint } from '../types/types.js'; +import type { PolkadotClient } from 'polkadot-api'; import { store } from '@mimir-wallet/service'; -import { ApiPromise } from '@polkadot/api'; +import { createClient } from 'polkadot-api'; +import { withPolkadotSdkCompat } from 'polkadot-api/polkadot-sdk-compat'; import { getTypesBundle } from '../types/api-types/index.js'; import { NETWORK_RPC_PREFIX } from '../utils/defaults.js'; import { getMetadata } from '../utils/metadata.js'; import { StoredRegistry } from '../utils/registry.js'; +import { ApiPromise$ } from './ApiPromise$.js'; import { ApiProvider } from './ApiProvider.js'; +import { PapiProviderAdapter } from './PapiProviderAdapter.js'; + +// Provider registry: stores ApiProvider references for each network +const providerRegistry = new Map(); /** * Get WebSocket endpoints with custom RPC URL support @@ -38,22 +45,63 @@ export function getEndpoints( return wsUrl ? [wsUrl, ...apiUrls] : apiUrls; } +/** + * Get ApiProvider for a network + * + * @param network - Network key + * @returns ApiProvider instance or undefined if not found + */ +export function getProvider(network: string): ApiProvider | undefined { + return providerRegistry.get(network); +} + +/** + * Create a papi PolkadotClient for a network + * Requires ApiProvider to be already created via createApi() + * + * @param network - Network key + * @returns PolkadotClient instance or null if provider not found + */ +export function createPapiClient(network: string): PolkadotClient | null { + const provider = providerRegistry.get(network); + + if (!provider) return null; + + const adapter = new PapiProviderAdapter(provider); + + return createClient(withPolkadotSdkCompat(adapter.getJsonRpcProvider())); +} + +/** + * Clear provider reference from registry + * Should be called when destroying a connection + * + * @param network - Network key + */ +export function clearProvider(network: string): void { + providerRegistry.delete(network); +} + /** * Create a new Polkadot API instance for a chain * * @param chain - Chain endpoint configuration * @returns Promise resolving to ApiPromise instance */ -export async function createApi(chain: Endpoint): Promise { +export async function createApi(chain: Endpoint): Promise { const endpoints = getEndpoints(Object.values(chain.wsUrl), chain.key); const provider = new ApiProvider(endpoints); + + // Store provider reference for papi adapter + providerRegistry.set(chain.key, provider); + const registry = new StoredRegistry(); const [metadata, typesBundle] = await Promise.all([ getMetadata(chain.key), getTypesBundle(), ]); - return new ApiPromise({ + return new ApiPromise$({ provider, registry: registry as any, typesBundle, diff --git a/packages/polkadot-core/src/api/index.ts b/packages/polkadot-core/src/api/index.ts index 13e91866..f786f3c6 100644 --- a/packages/polkadot-core/src/api/index.ts +++ b/packages/polkadot-core/src/api/index.ts @@ -2,7 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 export { ApiManager } from './ApiManager.js'; -export { createApi, getEndpoints } from './api-factory.js'; +export { + clearProvider, + createApi, + createPapiClient, + getEndpoints, + getProvider, +} from './api-factory.js'; +export { PapiProviderAdapter } from './PapiProviderAdapter.js'; +export type { + JsonRpcConnection, + JsonRpcProvider, +} from './PapiProviderAdapter.js'; export { getIdentityNetwork, resolveChain } from './chain-resolver.js'; export { useAllChainStatuses } from './useAllChainStatuses.js'; export { useAllChainsConnected } from './useAllChainsConnected.js'; diff --git a/packages/polkadot-core/src/chains/polkadot.json b/packages/polkadot-core/src/chains/polkadot.json index 329a4273..cdfb711c 100644 --- a/packages/polkadot-core/src/chains/polkadot.json +++ b/packages/polkadot-core/src/chains/polkadot.json @@ -220,27 +220,6 @@ "supportsProxy": true, "paraspellChain": "Acala" }, - { - "key": "phala", - "icon": "/chain-icons/phala.svg", - "tokenIcon": "/token-icons/PHA.svg", - "name": "Phala", - "relayChain": "polkadot", - "paraId": 2035, - "wsUrl": { - "Dwellir": "wss://phala-rpc.n.dwellir.com", - "OnFinality": "wss://phala.api.onfinality.io/public-ws", - "RadiumBlock": "wss://phala.public.curie.radiumblock.co/ws" - }, - "genesisHash": "0x1bb969d85965e4bb5a651abbedf21a54b6b31a21f66b5401cc3f1e286268d736", - "ss58Format": 30, - "explorerUrl": "https://phala.subscan.io/", - "nativeDecimals": 12, - "nativeToken": "PHA", - "supportsDryRun": false, - "supportsProxy": true, - "paraspellChain": "Phala" - }, { "key": "nexus", "icon": "/chain-icons/nexus.webp", diff --git a/packages/polkadot-core/src/index.ts b/packages/polkadot-core/src/index.ts index deaed0bf..47c4d0d7 100644 --- a/packages/polkadot-core/src/index.ts +++ b/packages/polkadot-core/src/index.ts @@ -72,7 +72,6 @@ export { // Type exports export type { Endpoint, - ValidApiState, Network, ChainStatus, ApiConnection, diff --git a/packages/polkadot-core/src/paraspell/api-client.ts b/packages/polkadot-core/src/paraspell/api-client.ts new file mode 100644 index 00000000..7f864f76 --- /dev/null +++ b/packages/polkadot-core/src/paraspell/api-client.ts @@ -0,0 +1,119 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * LightSpell API base URL + */ +const LIGHTSPELL_BASE_URL = 'https://api.lightspell.xyz/v5'; + +/** + * LightSpell API endpoints configuration + */ +export const LIGHTSPELL_ENDPOINTS = { + TRANSFER: `${LIGHTSPELL_BASE_URL}/x-transfer`, + XCM_FEE: `${LIGHTSPELL_BASE_URL}/xcm-fee`, + ROUTER: `${LIGHTSPELL_BASE_URL}/router`, + ROUTER_BEST_AMOUNT: `${LIGHTSPELL_BASE_URL}/router/best-amount-out`, + ROUTER_DRY_RUN: `${LIGHTSPELL_BASE_URL}/router/dry-run`, +} as const; + +/** + * LightSpell API error response format + */ +interface LightSpellErrorResponse { + message: string; + error: string; + statusCode: number; +} + +/** + * Custom error for LightSpell API failures + */ +export class LightSpellApiError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly errorType?: string, + public readonly endpoint?: string, + ) { + super(message); + this.name = 'LightSpellApiError'; + } +} + +/** + * JSON replacer for BigInt serialization + */ +const bigIntReplacer = (_: string, value: unknown): unknown => + typeof value === 'bigint' ? value.toString() : value; + +/** + * Parse error response from LightSpell API + */ +async function parseErrorResponse( + response: Response, + endpoint: string, +): Promise { + try { + const errorData: LightSpellErrorResponse = await response.json(); + + return new LightSpellApiError( + errorData.message, + errorData.statusCode, + errorData.error, + endpoint, + ); + } catch { + // Fallback if response is not JSON + return new LightSpellApiError( + `LightSpell API error: ${response.statusText}`, + response.status, + undefined, + endpoint, + ); + } +} + +/** + * Unified LightSpell API client for JSON responses + */ +export async function lightSpellPost( + endpoint: string, + body: Record, +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body, bigIntReplacer), + }); + + if (!response.ok) { + throw await parseErrorResponse(response, endpoint); + } + + return response.json(); +} + +/** + * LightSpell API client for text responses (transfer call data) + */ +export async function lightSpellPostText( + endpoint: string, + body: Record, +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body, bigIntReplacer), + }); + + if (!response.ok) { + throw await parseErrorResponse(response, endpoint); + } + + return response.text(); +} diff --git a/packages/polkadot-core/src/paraspell/currency.ts b/packages/polkadot-core/src/paraspell/currency.ts new file mode 100644 index 00000000..9e8f0f64 --- /dev/null +++ b/packages/polkadot-core/src/paraspell/currency.ts @@ -0,0 +1,62 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { + TCurrencyCore, + TCurrencyInputWithAmount, + TLocation, +} from './types.js'; +import type { AnyAssetInfo } from '@mimir-wallet/service'; + +import { Foreign, Native } from '@paraspell/sdk-core'; + +/** + * Build currency specification with amount for ParaSpell API calls + * + * Priority: isNative > location > assetId > symbol (Foreign) + */ +export function buildCurrencyWithAmount( + token: AnyAssetInfo, + amount: string, +): TCurrencyInputWithAmount { + if (token.location) { + return { location: token.location as TLocation, amount }; + } + + if (token.isNative) { + return { symbol: Native(token.symbol), amount }; + } + + if (token.assetId) { + return { id: token.assetId, amount }; + } + + // Fallback: use Foreign() for tokens without location/assetId + return { symbol: Foreign(token.symbol), amount }; +} + +/** + * Build currency core specification (without amount) for ParaSpell SDK + * + * Priority: isNative > location > assetId > symbol (Foreign) + * Unified with buildCurrencyWithAmount for consistency + */ +export function buildCurrencyCore(token: AnyAssetInfo): TCurrencyCore { + if (token.location) { + return { location: token.location as TLocation }; + } + + if (token.isNative) { + return { symbol: Native(token.symbol) }; + } + + if (token.assetId) { + return { id: token.assetId }; + } + + // Fallback: use Foreign() for tokens without location/assetId + return { symbol: Foreign(token.symbol) }; +} + +// Backward compatibility alias +export { buildCurrencyWithAmount as buildCurrencySpec }; diff --git a/packages/polkadot-core/src/paraspell/index.ts b/packages/polkadot-core/src/paraspell/index.ts index c10e0be7..683f6aa9 100644 --- a/packages/polkadot-core/src/paraspell/index.ts +++ b/packages/polkadot-core/src/paraspell/index.ts @@ -1,275 +1,49 @@ // Copyright 2023-2025 dev.mimir authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { Endpoint } from '../types/types.js'; -import type { AnyAssetInfo } from '@mimir-wallet/service'; - -import { +// Re-export all types +export type { + BuildSwapCallParams, + BuildXcmCallParams, + GetSwapEstimateParams, + GetXcmFeeParams, + SwapEstimateResult, + SwapRouteHop, + SwapTransaction, + SwapTransactionType, + TAssetInfo, + TLocation, + XcmFeeAsset, + XcmFeeInfo, +} from './types.js'; + +// Re-export paraspell utilities +export { + compareLocations, findAssetInfo, + getAssetLocation, + getAssets, isAssetXcEqual, normalizeLocation, - compareLocations, - getAssets, - getAssetLocation, - type TAssetInfo, - type TCurrencyCore, - type TCurrencyInputWithAmount, } from '@paraspell/assets'; -import { - Foreign, - getSupportedDestinations as paraspellGetSupportedDestinations, - Native, - type TLocation, - type TGetXcmFeeResult, -} from '@paraspell/sdk-core'; - -import { ApiManager } from '../api/ApiManager.js'; -import { allEndpoints, getRelayChainKey } from '../chains/config.js'; - -/** - * Fee asset information from dry run - * Extends TAssetInfo with fee amount for XCM transfers - */ -export type XcmFeeAsset = TAssetInfo & { - /** Fee amount in smallest unit */ - fee: string; -}; - -export function getSupportedDestinations( - source: string, - token?: AnyAssetInfo, -): Endpoint[] { - const sourceChain = allEndpoints.find( - (item) => item.key === source, - )?.paraspellChain; - - if (!sourceChain) { - return []; - } - - const sourceRelayChain = getRelayChainKey(source); - - if (!token) { - return allEndpoints.filter((item) => { - if (!item.paraspellChain) return false; - - const endpointRelayChain = item.relayChain || item.key; - - return endpointRelayChain === sourceRelayChain; - }); - } - - const supportedChains = paraspellGetSupportedDestinations( - sourceChain, - buildCurrencyCore(token), - ); - - return allEndpoints.filter((item) => { - if (!item.paraspellChain) return false; - const endpointRelayChain = item.relayChain || item.key; - - return ( - endpointRelayChain === sourceRelayChain && - supportedChains.includes(item.paraspellChain) - ); - }); -} - -// Build currency specification for ParaSpell API calls -export function buildCurrencySpec( - token: AnyAssetInfo, - amount: string, -): TCurrencyInputWithAmount { - // Priority: isNative > location > assetId - if (token.isNative) { - return { symbol: Native(token.symbol), amount: amount }; - } - - if (token.location) { - return { location: token.location as any, amount: amount }; - } - - if (token.assetId) { - return { id: token.assetId, amount: amount }; - } - - // Fallback: use Foreign() for tokens without location/assetId - return { symbol: Foreign(token.symbol), amount: amount }; -} - -export function buildCurrencyCore(token: AnyAssetInfo): TCurrencyCore { - // Priority: isNative > location > assetId - if (token.isNative) { - return { symbol: Native(token.symbol) }; - } - - if (token.location) { - return { location: token.location as any }; - } - - if (token.assetId) { - return { id: token.assetId }; - } - - // Fallback: use Foreign() for tokens without location/assetId - return { symbol: Foreign(token.symbol) }; -} - -/** - * Parameters for buildXcmCall - */ -export interface BuildXcmCallParams { - sourceChain: Endpoint; - destChain: Endpoint; - token: AnyAssetInfo; - amount: string; // Amount in smallest unit (e.g., planck) - recipient: string; - senderAddress: string; -} -/** - * Build XCM transfer call for submission - * Returns the extrinsic that can be signed and submitted - */ -export async function buildXcmCall(params: BuildXcmCallParams) { - const { sourceChain, destChain, token, amount, recipient, senderAddress } = - params; - - const sourceParaspell = sourceChain.paraspellChain; - const destParaspell = destChain.paraspellChain; - - if (!sourceParaspell || !destParaspell) { - throw new Error('Chain does not support XCM transfers'); - } - - const api = await ApiManager.getInstance().getApi(sourceChain.key); - - // Build currency spec - const currencySpec = buildCurrencySpec(token, amount); - - const result: string = await fetch( - 'https://api.lightspell.xyz/v5/x-transfer', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: sourceParaspell, - to: destParaspell, - currency: currencySpec, - address: recipient, - senderAddress: senderAddress, - }), - }, - ).then((res) => res.text()); - - return api.tx(api.createType('Call', result)); -} - -/** - * Parameters for getXcmFee - */ -export interface GetXcmFeeParams { - sourceChain: Endpoint; - destChain: Endpoint; - token: AnyAssetInfo; - recipient: string; - senderAddress: string; -} - -/** - * Get XCM fee from origin chain only (without amount) - * Uses ParaSpell's getXcmFee which automatically falls back to PaymentInfo if DryRun fails - */ -export async function getXcmFee(params: GetXcmFeeParams) { - const { sourceChain, destChain, token, recipient, senderAddress } = params; - - const sourceParaspell = sourceChain.paraspellChain; - const destParaspell = destChain.paraspellChain; - - if (!sourceParaspell || !destParaspell) { - throw new Error('Not Support chain'); - } - - // Use a minimal amount for fee estimation - XCM fees don't depend on transfer amount - const currencySpec = buildCurrencySpec(token, '1'); - - const result: TGetXcmFeeResult = await fetch( - 'https://api.lightspell.xyz/v5/xcm-fee', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: sourceParaspell, - to: destParaspell, - currency: currencySpec, - address: recipient, - senderAddress: senderAddress, - }), - }, - ).then((res) => res.json()); - - return result; -} - -/** - * XCM fee information with origin, destination fees and ED - */ -export interface XcmFeeInfo { - originFee: XcmFeeAsset; - destFee?: XcmFeeAsset; - destED?: string; - hopFees: XcmFeeAsset[]; - error?: string; -} - -/** - * Get XCM fee info (origin + destination) without requiring amount - * Wraps getXcmFee and extracts fee data in a simplified format - */ -export async function getOriginXcmFee( - params: GetXcmFeeParams, -): Promise { - const result = await getXcmFee(params); - - let errorMsg: string | undefined; - - // Check for failure in the transfer chain - if (result.failureChain && result.failureReason) { - errorMsg = `${result.failureChain}: ${result.failureReason}`; - } - - const originFee: XcmFeeAsset = { - fee: result.origin.fee.toString(), - symbol: result.origin.asset.symbol, - decimals: result.origin.asset.decimals, - }; - - const destFee: XcmFeeAsset | undefined = result.destination - ? { - fee: result.destination.fee.toString(), - symbol: result.destination.asset.symbol, - decimals: result.destination.asset.decimals, - } - : undefined; - - const destED = result.destination?.asset.existentialDeposit?.toString(); +// Currency utilities +export { + buildCurrencyCore, + buildCurrencySpec, + buildCurrencyWithAmount, +} from './currency.js'; - // Note: getXcmFee's hops array doesn't include fee details (unlike getTransferInfo) - // For multi-hop transfers, getTransferInfo should be used instead - const hopFees: XcmFeeAsset[] = []; +// Transfer functions +export { + buildXcmCall, + getOriginXcmFee, + getSupportedDestinations, + getXcmFee, +} from './transfer.js'; - return { originFee, destFee, destED, hopFees, error: errorMsg }; -} +// Router functions +export { buildSwapCall, getSwapEstimate } from './router.js'; -export type { TAssetInfo, TLocation, findAssetInfo }; -export { - normalizeLocation, - compareLocations, - getAssets, - getAssetLocation, - isAssetXcEqual, -}; +// API client (for advanced usage) +export { LightSpellApiError, LIGHTSPELL_ENDPOINTS } from './api-client.js'; diff --git a/packages/polkadot-core/src/paraspell/router.ts b/packages/polkadot-core/src/paraspell/router.ts new file mode 100644 index 00000000..923d37ce --- /dev/null +++ b/packages/polkadot-core/src/paraspell/router.ts @@ -0,0 +1,303 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { + BuildSwapCallParams, + DryRunChainResult, + GetSwapEstimateParams, + LightSpellBestAmountResponse, + LightSpellDryRunResponse, + LightSpellRouterTransaction, + SwapEstimateResult, + SwapRouteHop, + SwapTransaction, + XcmFeeAsset, +} from './types.js'; +import type { AnyAssetInfo } from '@mimir-wallet/service'; + +import { ApiManager } from '../api/ApiManager.js'; + +import { LIGHTSPELL_ENDPOINTS, lightSpellPost } from './api-client.js'; +import { buildCurrencyCore } from './currency.js'; +import { buildXcmFeeAsset, calculateExchangeRate } from './utils.js'; + +/** + * Default exchange for cross-chain swaps + */ +const HYDRATION_DEX = 'HydrationDex'; + +/** + * Build swap transactions using ParaSpell XCM Router + */ +export async function buildSwapCall( + params: BuildSwapCallParams, +): Promise { + const { + fromChain, + toChain, + fromToken, + toToken, + amount, + slippagePct, + senderAddress, + recipient, + exchange, + } = params; + + const fromParaspell = fromChain.paraspellChain; + const toParaspell = toChain.paraspellChain; + + if (!fromParaspell || !toParaspell) { + throw new Error('Chain does not support XCM Router'); + } + + const [result, api] = await Promise.all([ + lightSpellPost(LIGHTSPELL_ENDPOINTS.ROUTER, { + from: fromParaspell, + exchange, + to: toParaspell, + currencyFrom: buildCurrencyCore(fromToken), + currencyTo: buildCurrencyCore(toToken), + amount, + slippagePct, + recipientAddress: recipient, + senderAddress, + }), + ApiManager.getInstance().getApi(fromChain.key), + ]); + + return result.map((item) => ({ + chain: item.chain, + type: item.type, + tx: api.tx(api.createType('Call', item.tx)), + amountOut: BigInt(item.amountOut), + })); +} + +/** + * Get the best output amount and selected exchange + */ +export async function getBestAmountOut(params: { + fromChain: { paraspellChain?: string }; + toChain: { paraspellChain?: string }; + fromToken: AnyAssetInfo; + toToken: AnyAssetInfo; + amount: string; +}): Promise<{ amountOut: bigint; exchange: string }> { + const { fromChain, toChain, fromToken, toToken, amount } = params; + + const result = await lightSpellPost( + LIGHTSPELL_ENDPOINTS.ROUTER_BEST_AMOUNT, + { + from: fromChain.paraspellChain, + to: toChain.paraspellChain, + exchange: HYDRATION_DEX, + currencyFrom: buildCurrencyCore(fromToken), + currencyTo: buildCurrencyCore(toToken), + amount, + }, + ); + + return { + amountOut: result.amountOut, + exchange: result.exchange, + }; +} + +/** + * Internal result type for getSwapDryRun + */ +interface DryRunResult { + success: boolean; + originFee?: XcmFeeAsset; + destFee?: XcmFeeAsset; + hops: SwapRouteHop[]; + error?: string; +} + +/** + * Format chain error message for user display + */ +function formatChainError( + chainType: string, + result: DryRunChainResult, +): string { + if (result.success) return ''; + + const parts = [`${chainType}: ${result.failureReason}`]; + + if (result.failureSubReason) { + parts.push(result.failureSubReason); + } + + return parts.join(' - '); +} + +/** + * Format top-level dry-run error for user display + */ +function formatDryRunError(response: LightSpellDryRunResponse): string { + const parts: string[] = []; + + if (response.failureReason) { + parts.push(response.failureReason); + } + + if (response.failureSubReason) { + parts.push(response.failureSubReason); + } + + if (response.failureChain) { + parts.push(`on ${response.failureChain}`); + } + + return parts.join(': ') || 'Unknown error'; +} + +/** + * Get swap dry-run result with fee estimation and validation + * Always parses the full response even if there are failures + */ +export async function getSwapDryRun( + params: GetSwapEstimateParams, + exchange: string, +): Promise { + const { + fromChain, + toChain, + fromToken, + toToken, + amount, + slippagePct, + senderAddress, + recipient, + } = params; + + const fromParaspell = fromChain.paraspellChain; + const toParaspell = toChain.paraspellChain; + + if (!fromParaspell || !toParaspell) { + return { + success: false, + hops: [], + error: 'Chain does not support XCM Router', + }; + } + + const response = await lightSpellPost( + LIGHTSPELL_ENDPOINTS.ROUTER_DRY_RUN, + { + from: fromParaspell, + exchange, + to: toParaspell, + currencyFrom: buildCurrencyCore(fromToken), + currencyTo: buildCurrencyCore(toToken), + amount, + slippagePct, + recipientAddress: recipient, + senderAddress, + }, + ); + + // Collect errors but continue parsing + const errors: string[] = []; + + // Check for top-level failure + if (response.failureReason) { + errors.push(formatDryRunError(response)); + } + + // Check origin result + if (response.origin && !response.origin.success) { + errors.push(formatChainError('Origin', response.origin)); + } + + // Check destination result + if (response.destination && !response.destination.success) { + errors.push(formatChainError('Destination', response.destination)); + } + + // Check hops for failures + for (const hop of response.hops ?? []) { + if (!hop.result.success) { + errors.push(formatChainError(hop.chain, hop.result)); + } + } + + // Build hops from response (always parse) + const hops: SwapRouteHop[] = (response.hops ?? []).map( + (hop: { chain: string; result: DryRunChainResult }) => ({ + chain: hop.chain, + isExchange: hop.chain === exchange, + fee: hop.result.success + ? buildXcmFeeAsset(hop.result.asset, hop.result.fee) + : undefined, + }), + ); + + // Parse origin fee if available (only when success) + const originFee = + response.origin?.success === true + ? buildXcmFeeAsset(response.origin.asset, response.origin.fee) + : undefined; + + // Parse destination fee if available (only when success) + const destFee = + response.destination?.success === true + ? buildXcmFeeAsset(response.destination.asset, response.destination.fee) + : undefined; + + const success = errors.length === 0; + + return { + success, + originFee, + destFee, + hops, + error: success ? undefined : errors.join('; '), + }; +} + +/** + * Get full swap estimate including output amount and fees + */ +export async function getSwapEstimate( + params: GetSwapEstimateParams, +): Promise { + // Parallel API calls with fixed exchange + const [{ amountOut }, dryRunResult] = await Promise.all([ + getBestAmountOut({ + fromChain: params.fromChain, + toChain: params.toChain, + fromToken: params.fromToken, + toToken: params.toToken, + amount: params.amount, + }), + getSwapDryRun(params, HYDRATION_DEX), + ]); + + const inputAmount = BigInt(params.amount); + const inputDecimals = params.fromToken.decimals || 10; + const outputDecimals = params.toToken.decimals || 10; + + // Default empty fee for failed dry-run + const emptyFee: XcmFeeAsset = { symbol: '', decimals: 0, fee: '0' }; + + return { + outputAmount: amountOut.toString(), + exchange: HYDRATION_DEX, + originFee: dryRunResult.originFee ?? emptyFee, + destFee: dryRunResult.destFee, + hops: dryRunResult.hops, + exchangeRate: calculateExchangeRate( + inputAmount, + amountOut, + params.fromToken.symbol, + params.toToken.symbol, + inputDecimals, + outputDecimals, + ), + dryRunSuccess: dryRunResult.success, + dryRunError: dryRunResult.error, + }; +} diff --git a/packages/polkadot-core/src/paraspell/transfer.ts b/packages/polkadot-core/src/paraspell/transfer.ts new file mode 100644 index 00000000..16d20bc7 --- /dev/null +++ b/packages/polkadot-core/src/paraspell/transfer.ts @@ -0,0 +1,142 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { + BuildXcmCallParams, + GetXcmFeeParams, + TGetXcmFeeResult, + XcmFeeInfo, +} from './types.js'; +import type { Endpoint } from '../types/types.js'; +import type { AnyAssetInfo } from '@mimir-wallet/service'; + +import { getSupportedDestinations as paraspellGetSupportedDestinations } from '@paraspell/sdk-core'; + +import { ApiManager } from '../api/ApiManager.js'; +import { allEndpoints, getRelayChainKey } from '../chains/config.js'; + +import { + LIGHTSPELL_ENDPOINTS, + lightSpellPost, + lightSpellPostText, +} from './api-client.js'; +import { buildCurrencyCore, buildCurrencyWithAmount } from './currency.js'; +import { buildXcmFeeAsset } from './utils.js'; + +/** + * Get supported XCM destination chains for a source chain and optional token + */ +export function getSupportedDestinations( + source: string, + token?: AnyAssetInfo, +): Endpoint[] { + const sourceChain = allEndpoints.find((item) => item.key === source); + const sourceParaspell = sourceChain?.paraspellChain; + + if (!sourceParaspell) { + return []; + } + + const sourceRelayChain = getRelayChainKey(source); + + const sameRelayChain = (endpoint: Endpoint) => { + if (!endpoint.paraspellChain) return false; + const endpointRelayChain = endpoint.relayChain || endpoint.key; + + return endpointRelayChain === sourceRelayChain; + }; + + if (!token) { + return allEndpoints.filter(sameRelayChain); + } + + const supportedChains = paraspellGetSupportedDestinations( + sourceParaspell, + buildCurrencyCore(token), + ); + + return allEndpoints.filter( + (endpoint) => + sameRelayChain(endpoint) && + supportedChains.includes(endpoint.paraspellChain!), + ); +} + +/** + * Build XCM transfer call for submission + */ +export async function buildXcmCall(params: BuildXcmCallParams) { + const { fromChain, toChain, token, amount, recipient, senderAddress } = + params; + + const sourceParaspell = fromChain.paraspellChain; + const destParaspell = toChain.paraspellChain; + + if (!sourceParaspell || !destParaspell) { + throw new Error('Chain does not support XCM transfers'); + } + + const api = await ApiManager.getInstance().getApi(fromChain.key); + const currencySpec = buildCurrencyWithAmount(token, amount); + + const callData = await lightSpellPostText(LIGHTSPELL_ENDPOINTS.TRANSFER, { + from: sourceParaspell, + to: destParaspell, + currency: currencySpec, + address: recipient, + senderAddress, + }); + + return api.tx(api.createType('Call', callData)); +} + +/** + * Get XCM fee estimation + */ +export async function getXcmFee( + params: GetXcmFeeParams, +): Promise> { + const { fromChain, toChain, token, recipient, senderAddress } = params; + + const sourceParaspell = fromChain.paraspellChain; + const destParaspell = toChain.paraspellChain; + + if (!sourceParaspell || !destParaspell) { + throw new Error('Chain does not support XCM transfers'); + } + + // Use a minimal amount for fee estimation - XCM fees don't depend on transfer amount + const currencySpec = buildCurrencyWithAmount(token, '1'); + + return lightSpellPost>(LIGHTSPELL_ENDPOINTS.XCM_FEE, { + from: sourceParaspell, + to: destParaspell, + currency: currencySpec, + address: recipient, + senderAddress, + }); +} + +/** + * Get XCM fee info with structured format + */ +export async function getOriginXcmFee( + params: GetXcmFeeParams, +): Promise { + const result = await getXcmFee(params); + + const error = + result.failureChain && result.failureReason + ? `${result.failureChain}: ${result.failureReason}` + : undefined; + + return { + originFee: buildXcmFeeAsset(result.origin.asset, result.origin.fee), + destFee: result.destination + ? buildXcmFeeAsset(result.destination.asset, result.destination.fee) + : undefined, + destED: result.destination?.asset.existentialDeposit?.toString(), + hopFees: [], + error, + }; +} diff --git a/packages/polkadot-core/src/paraspell/types.ts b/packages/polkadot-core/src/paraspell/types.ts new file mode 100644 index 00000000..c8bbf224 --- /dev/null +++ b/packages/polkadot-core/src/paraspell/types.ts @@ -0,0 +1,201 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Endpoint } from '../types/types.js'; +import type { AnyAssetInfo } from '@mimir-wallet/service'; +import type { + TAssetInfo, + TCurrencyCore, + TCurrencyInputWithAmount, +} from '@paraspell/assets'; +import type { TGetXcmFeeResult, TLocation } from '@paraspell/sdk-core'; +import type { SubmittableExtrinsic } from '@polkadot/api/types'; + +// ============================================ +// Common Types +// ============================================ + +/** + * Fee asset information with fee amount + */ +export interface XcmFeeAsset { + symbol: string; + decimals: number; + fee: string; +} + +/** + * XCM fee breakdown for transfers + */ +export interface XcmFeeInfo { + originFee: XcmFeeAsset; + destFee?: XcmFeeAsset; + destED?: string; + hopFees: XcmFeeAsset[]; + error?: string; +} + +// ============================================ +// Transfer Types +// ============================================ + +/** + * Parameters for XCM transfer call building + */ +export interface BuildXcmCallParams { + /** Source chain configuration */ + fromChain: Endpoint; + /** Destination chain configuration */ + toChain: Endpoint; + /** Token to transfer */ + token: AnyAssetInfo; + /** Amount in smallest unit (e.g., planck) */ + amount: string; + /** Recipient address on destination chain */ + recipient: string; + /** Sender address on source chain */ + senderAddress: string; +} + +/** + * Parameters for XCM fee estimation + */ +export interface GetXcmFeeParams { + fromChain: Endpoint; + toChain: Endpoint; + token: AnyAssetInfo; + recipient: string; + senderAddress: string; +} + +// ============================================ +// Router Types +// ============================================ + +export type SwapTransactionType = 'TRANSFER' | 'SWAP' | 'SWAP_AND_TRANSFER'; + +export interface SwapTransaction { + tx: SubmittableExtrinsic<'promise'>; + chain: string; + type: SwapTransactionType; + amountOut?: bigint; +} + +export interface BuildSwapCallParams { + fromChain: Endpoint; + toChain: Endpoint; + fromToken: AnyAssetInfo; + toToken: AnyAssetInfo; + amount: string; + slippagePct: string; + senderAddress: string; + recipient: string; + exchange: string; +} + +export interface GetSwapEstimateParams { + fromChain: Endpoint; + toChain: Endpoint; + fromToken: AnyAssetInfo; + toToken: AnyAssetInfo; + amount: string; + slippagePct: string; + senderAddress: string; + recipient: string; +} + +/** + * A hop in the swap route path + */ +export interface SwapRouteHop { + /** ParaSpell chain name */ + chain: string; + /** Whether this hop is an exchange (DEX) */ + isExchange: boolean; + /** Fee for this hop (if available) */ + fee?: XcmFeeAsset; +} + +export interface SwapEstimateResult { + outputAmount: string; + exchange: string; + originFee: XcmFeeAsset; + destFee?: XcmFeeAsset; + /** Route hops between origin and destination */ + hops: SwapRouteHop[]; + exchangeRate: string; + /** Whether dry-run validation succeeded */ + dryRunSuccess: boolean; + /** Error message from dry-run (for UI display) */ + dryRunError?: string; +} + +// ============================================ +// LightSpell API Response Types +// ============================================ + +export interface LightSpellRouterTransaction { + chain: string; + type: SwapTransactionType; + tx: string; + amountOut: string; +} + +export interface LightSpellBestAmountResponse { + amountOut: bigint; + exchange: string; +} + +/** + * Chain result from dry-run (success case) + */ +export interface DryRunChainSuccess { + success: true; + fee: bigint; + asset: TAssetInfo; +} + +/** + * Chain result from dry-run (failure case) + */ +export interface DryRunChainFailure { + success: false; + failureReason: string; + failureSubReason?: string; +} + +export type DryRunChainResult = DryRunChainSuccess | DryRunChainFailure; + +/** + * LightSpell API response for router dry-run + * Matches the structure returned by ROUTER_DRY_RUN endpoint + */ +export interface LightSpellDryRunResponse { + /** Top-level failure reason */ + failureReason?: string; + /** Detailed failure reason */ + failureSubReason?: string; + /** Chain where failure occurred */ + failureChain?: string; + /** Origin chain result */ + origin: DryRunChainResult; + /** Destination chain result */ + destination?: DryRunChainResult; + /** Intermediate hops in the route */ + hops: Array<{ + chain: string; + result: DryRunChainResult; + }>; +} + +// ============================================ +// Re-exports from paraspell +// ============================================ + +export type { + TAssetInfo, + TCurrencyCore, + TCurrencyInputWithAmount, + TGetXcmFeeResult, + TLocation, +}; diff --git a/packages/polkadot-core/src/paraspell/utils.ts b/packages/polkadot-core/src/paraspell/utils.ts new file mode 100644 index 00000000..4ad8ff00 --- /dev/null +++ b/packages/polkadot-core/src/paraspell/utils.ts @@ -0,0 +1,39 @@ +// Copyright 2023-2025 dev.mimir authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { XcmFeeAsset } from './types.js'; +import type { TAssetInfo } from '@paraspell/assets'; + +/** + * Build XcmFeeAsset from TAssetInfo and fee + */ +export function buildXcmFeeAsset( + asset: TAssetInfo, + fee: bigint | number, +): XcmFeeAsset { + return { + symbol: asset.symbol, + decimals: asset.decimals, + fee: fee.toString(), + }; +} + +/** + * Calculate exchange rate string + */ +export function calculateExchangeRate( + inputAmount: bigint, + outputAmount: bigint, + fromSymbol: string, + toSymbol: string, + inputDecimals: number, + outputDecimals: number, +): string { + if (inputAmount === 0n) return ''; + + const normalizedInput = Number(inputAmount) / Math.pow(10, inputDecimals); + const normalizedOutput = Number(outputAmount) / Math.pow(10, outputDecimals); + const rate = normalizedOutput / normalizedInput; + + return `1 ${fromSymbol} = ${rate.toFixed(6)} ${toSymbol}`; +} diff --git a/packages/polkadot-core/src/types/index.ts b/packages/polkadot-core/src/types/index.ts index e90b4434..b4a74dc1 100644 --- a/packages/polkadot-core/src/types/index.ts +++ b/packages/polkadot-core/src/types/index.ts @@ -4,10 +4,6 @@ export type { Endpoint, Network, - ApiState, - ApiProps, - ApiContextProps, - ValidApiState, ChainStatus, ApiConnection, Ss58FormatControl, diff --git a/packages/polkadot-core/src/types/types.ts b/packages/polkadot-core/src/types/types.ts index 9bfd6944..523a82ae 100644 --- a/packages/polkadot-core/src/types/types.ts +++ b/packages/polkadot-core/src/types/types.ts @@ -1,8 +1,8 @@ // Copyright 2023-2025 dev.mimir authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { ApiPromise$ } from '../api/ApiPromise$.js'; import type { TSubstrateChain } from '@paraspell/sdk-common'; -import type { ApiPromise } from '@polkadot/api'; import type { HexString } from '@polkadot/util/types'; import kusamaChains from '../chains/kusama.json' with { type: 'json' }; @@ -93,36 +93,6 @@ export type Network = Endpoint & { enabled: boolean; }; -export interface ApiState { - isApiReady: boolean; - tokenSymbol: string; - genesisHash: HexString; -} - -export interface ApiProps extends ApiState { - api?: ApiPromise | null; - apiError: string | null; - isApiInitialized: boolean; - network: string; - chain: Endpoint; -} - -export interface ApiContextProps extends ValidApiState, Omit { - chainSS58: number; - ss58Chain: string; - setSs58Chain: (chain: string) => void; - allApis: Record; - setNetwork: (network: string) => string | null; -} - -export type ValidApiState = ApiState & { - api: ApiPromise; - chain: Endpoint; - apiError: string | null; - isApiInitialized: boolean; - network: string; -}; - // New types for refactored API management /** @@ -143,7 +113,7 @@ export interface ChainStatus { * API connection state managed by ApiManager */ export interface ApiConnection { - api: ApiPromise | null; + api: ApiPromise$ | null; chain: Endpoint; network: string; status: ChainStatus; diff --git a/packages/service/README.md b/packages/service/README.md index 65b194d2..bc986007 100644 --- a/packages/service/README.md +++ b/packages/service/README.md @@ -1,50 +1,20 @@ # @mimir-wallet/service -A comprehensive service layer package for Mimir Wallet, providing client-side services, data fetching, storage management, and real-time communication capabilities. - -## Overview - -This package implements the service layer architecture for Mimir Wallet, offering a clean abstraction over complex backend operations. It includes client service management, query handling, storage solutions, and WebSocket-based real-time updates. +Service layer for Mimir Wallet, providing HTTP client, data fetching, storage, and real-time communication. ## Features -### πŸ”Œ Client Service Management -- **Centralized service architecture** - Clean abstraction for backend communications -- **Configuration management** - Flexible configuration for different environments -- **Service lifecycle** - Proper initialization and cleanup - -### πŸ“Š Data Fetching & Caching -- **React Query integration** - Powerful data fetching with automatic caching -- **Custom hooks** - Purpose-built hooks for common data operations -- **Error handling** - Comprehensive error handling and retry logic -- **Optimistic updates** - Smooth UX with optimistic UI updates - -### πŸ’Ύ Storage Solutions -- **Multiple storage backends** - Local storage, session storage support -- **Type-safe storage** - TypeScript-first storage APIs -- **Storage utilities** - Helper functions for common storage operations -- **Data persistence** - Reliable data persistence across sessions - -### πŸ”„ Real-time Communication -- **WebSocket integration** - Real-time updates via Socket.io -- **Connection management** - Automatic reconnection and error recovery -- **Event handling** - Structured event handling system -- **Context providers** - React context for socket state management +- **Client Service** - Centralized HTTP client with configuration management +- **React Query** - Data fetching with automatic caching and error handling +- **Storage** - Type-safe localStorage and sessionStorage wrappers +- **WebSocket** - Real-time updates via Socket.io ## Installation -This package is part of the Mimir Wallet monorepo and is typically not installed separately. However, if you need to use it in your own project: - ```bash pnpm add @mimir-wallet/service ``` -### Peer Dependencies - -```bash -pnpm add react react-dom -``` - ## Usage ### Service Initialization @@ -52,78 +22,43 @@ pnpm add react react-dom ```typescript import { initService, service } from '@mimir-wallet/service'; -// Initialize service with gateway URL initService('https://api.mimir.global'); - -// Use service instance -const result = await service.someMethod(); +const data = await service.getAccountInfo(address); ``` -### React Query Integration +### React Query ```typescript import { QueryProvider } from '@mimir-wallet/service'; -import { QueryClient } from '@tanstack/react-query'; - -const queryClient = new QueryClient(); function App() { return ( - + ); } ``` -### Using Custom Hooks - -```typescript -import { useClientQuery, useStore } from '@mimir-wallet/service'; - -function DataComponent() { - // Fetch data with automatic caching - const { data, isLoading, error } = useClientQuery({ - queryKey: ['user-data'], - queryFn: () => service.getUserData() - }); - - // Use local storage - const [value, setValue] = useStore('my-key', 'default-value'); - - return ( -
- {isLoading ? 'Loading...' : data} -
- ); -} -``` - -### Storage Management +### Storage ```typescript -import { LocalStore, SessionStorage } from '@mimir-wallet/service'; - -// Local storage operations -const localStore = new LocalStore('my-namespace'); -localStore.set('key', { some: 'data' }); -const data = localStore.get('key'); +import { store } from '@mimir-wallet/service'; -// Session storage operations -const sessionStore = new SessionStorage(); -sessionStore.setItem('temp-data', JSON.stringify(value)); +store.set('key', { data: 'value' }); +const data = store.get('key'); ``` -### WebSocket Integration +### WebSocket ```typescript -import { SocketProvider, useSocket } from '@mimir-wallet/service'; +import { TransactionSocketProvider, useSocket } from '@mimir-wallet/service'; function App() { return ( - + - + ); } @@ -131,192 +66,12 @@ function SocketComponent() { const socket = useSocket(); useEffect(() => { - socket?.on('update', (data) => { - console.log('Received update:', data); - }); - - return () => { - socket?.off('update'); - }; + socket?.on('update', (data) => console.log(data)); + return () => socket?.off('update'); }, [socket]); - - return
Socket Status: {socket?.connected ? 'Connected' : 'Disconnected'}
; -} -``` - -## API Reference - -### Core Services - -#### `initService(clientGateway: string): void` -Initialize the service with the provided gateway URL. - -#### `service: ClientService` -Global service instance for making API calls. - -### Storage - -#### `LocalStore` -Type-safe local storage wrapper with namespace support. - -```typescript -class LocalStore { - constructor(namespace?: string); - set(key: string, value: T): void; - get(key: string): T | null; - remove(key: string): void; - clear(): void; -} -``` - -#### `SessionStorage` -Session storage implementation. - -```typescript -class SessionStorage { - setItem(key: string, value: string): void; - getItem(key: string): string | null; - removeItem(key: string): void; - clear(): void; -} -``` - -### React Hooks - -#### `useClientQuery(options: QueryOptions): QueryResult` -Enhanced React Query hook with service integration. - -#### `useStore(key: string, defaultValue: T): [T, (value: T) => void]` -Hook for managing local storage state. - -### WebSocket - -#### `SocketProvider` -React context provider for WebSocket connections. - -#### `useSocket(): Socket | null` -Hook to access the current socket instance. - -## Configuration - -### Client Configuration - -Configure the service client: - -```typescript -import { ClientService } from '@mimir-wallet/service'; - -const service = ClientService.create('https://api.mimir.global', { - timeout: 10000, - retries: 3, - headers: { - 'Authorization': 'Bearer token' - } -}); -``` - -### Query Configuration - -Configure React Query defaults: - -```typescript -import { QueryClient } from '@tanstack/react-query'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, // 5 minutes - retry: 3 - } - } -}); -``` - -## Architecture - -``` -@mimir-wallet/service/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ client-service/ # Service layer implementation -β”‚ β”œβ”€β”€ hooks/ # React hooks -β”‚ β”œβ”€β”€ query/ # React Query integration -β”‚ β”œβ”€β”€ socket/ # WebSocket management -β”‚ β”œβ”€β”€ store/ # Storage solutions -β”‚ └── config.ts # Configuration -``` - -## Error Handling - -The package provides comprehensive error handling: - -```typescript -import { FetchError, NetworkError } from '@mimir-wallet/service'; - -try { - const result = await service.someMethod(); -} catch (error) { - if (error instanceof FetchError) { - // Handle fetch-specific errors - } else if (error instanceof NetworkError) { - // Handle network errors - } -} -``` - -## Best Practices - -### Query Keys - -Use consistent query key patterns: - -```typescript -const queryKeys = { - user: (id: string) => ['user', id], - transactions: (address: string) => ['transactions', address], - balance: (address: string, chain: string) => ['balance', address, chain] -}; -``` - -### Storage Namespacing - -Use namespaced storage to avoid conflicts: - -```typescript -const userStore = new LocalStore('user'); -const settingsStore = new LocalStore('settings'); -``` - -### Error Boundaries - -Wrap components with error boundaries for better UX: - -```typescript -import { ErrorBoundary } from 'react-error-boundary'; - -function App() { - return ( - }> - - - - - ); } ``` -## TypeScript Support - -Full TypeScript support with: - -- Type-safe service methods -- Generic storage operations -- Strongly typed query results -- Proper error type definitions - -## Contributing - -This package is part of the Mimir Wallet monorepo. Please see the main [Contributing Guide](../../README.md#contributing) for details. - ## License -Licensed under the Apache License, Version 2.0. See [LICENSE](../../LICENSE) for details. +[Apache License 2.0](../../LICENSE) diff --git a/packages/ui/README.md b/packages/ui/README.md index ff8456c7..135a9274 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,82 +1,29 @@ # @mimir-wallet/ui -A modern React UI component library built for Mimir Wallet, featuring ShadCN/UI components with Radix UI primitives, designed for enterprise blockchain applications. - -## Overview - -This package provides a comprehensive set of React components using **ShadCN/UI architecture** - unstyled, accessible components built on Radix UI primitives with Tailwind CSS styling. +React component library for Mimir Wallet, built on ShadCN/UI with Radix UI primitives and Tailwind CSS. ## Features -### Modern Design System -- **ShadCN/UI Foundation** - Unstyled, accessible components built on Radix UI -- **Radix UI Primitives** - Industry-leading accessibility as building blocks -- **Class Variance Authority (CVA)** - Type-safe component variants -- **Tailwind CSS v4.1** - Modern utility-first styling with latest features -- **Dark/Light Theme Support** - Consistent theming across components - -### Enhanced Accessibility -- **Radix UI Accessibility** - Industry-leading accessibility for all components -- **Keyboard Navigation** - Full keyboard support across all components -- **Screen Reader Optimized** - Enhanced assistive technology support -- **ARIA Support** - Comprehensive ARIA implementation - -### Component Categories - -#### Form Controls -- Button, Input, Textarea, Select, Checkbox, Switch, Combobox, Autocomplete - -#### Overlays & Dialogs -- Dialog, Drawer, Popover, Tooltip, DropdownMenu - -#### Feedback -- Alert, AlertTitle, AlertDescription, Spinner, Skeleton - -#### Data Display -- Table, Avatar, Badge, Card, Tabs - -#### Layout -- Divider, ScrollArea, Collapsible, Sidebar - -### Developer Experience -- **TypeScript First** - Full type safety with inference -- **Tree Shaking** - Optimized bundle size -- **Component Variants** - Type-safe styling variants with CVA -- **Utility Functions** - `cn()` for class merging +- **ShadCN/UI Architecture** - Unstyled, accessible components +- **Radix UI Primitives** - Industry-leading accessibility +- **Tailwind CSS** - Utility-first styling +- **CVA Variants** - Type-safe component variants +- **Dark/Light Theme** - Built-in theme support ## Installation -This package is part of the Mimir Wallet monorepo: - ```bash pnpm add @mimir-wallet/ui ``` -### Dependencies - -```json -{ - "dependencies": { - "@radix-ui/*": "^1.x", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "tailwind-merge": "^3.3.1" - } -} -``` - ## Setup -### Import Styles - ```typescript import '@mimir-wallet/ui/styles.css'; ``` ## Usage -### Basic Components - ```typescript import { Button, @@ -86,164 +33,36 @@ import { DialogHeader, DialogTitle, Alert, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, - cn + cn, } from '@mimir-wallet/ui'; -// Button with CVA variants - -// Input - - // Dialog - Transfer Confirmation + Confirm {/* content */} -// DropdownMenu with multi-select - - - - - - {options.map((option) => ( - e.preventDefault()} - onCheckedChange={(checked) => { - // Handle selection logic - }} - > - {option.name} - - ))} - - -``` - -### Data Display - -```typescript -import { Table, TableHeader, TableBody, TableRow, TableCell, Avatar, Card, CardHeader, CardTitle, CardContent } from '@mimir-wallet/ui'; - -// Table - - - - Address - Balance - - - - {/* rows */} - -
- -// Card with Avatar - - - Account Info - - - -
-

{account.name}

-

{account.address}

-
-
-
+// Class merging utility +
``` -### Utility Functions - -```typescript -import { cn } from '@mimir-wallet/ui'; - -// Class merging with conflict resolution -const className = cn( - "base-styles", - "conditional-styles", - isActive && "active-styles", - props.className -); -``` - -## Architecture - -``` -@mimir-wallet/ui/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ shadcn/ # ShadCN/UI components -β”‚ β”‚ β”œβ”€β”€ button.tsx # Button with CVA variants -β”‚ β”‚ β”œβ”€β”€ input.tsx # Input component -β”‚ β”‚ β”œβ”€β”€ dialog.tsx # Dialog system -β”‚ β”‚ β”œβ”€β”€ alert.tsx # Alert component -β”‚ β”‚ β”œβ”€β”€ dropdown-menu.tsx # Dropdown menu -β”‚ β”‚ β”œβ”€β”€ table.tsx # Table component -β”‚ β”‚ β”œβ”€β”€ tabs.tsx # Tabs component -β”‚ β”‚ β”œβ”€β”€ avatar.tsx # Avatar component -β”‚ β”‚ └── ... # Other components -β”‚ β”œβ”€β”€ lib/ # Utility functions -β”‚ β”‚ └── utils.ts # cn() class merger -β”‚ β”œβ”€β”€ hooks/ # Custom hooks -β”‚ └── index.ts # Unified exports -``` - -## Best Practices - -### Styling -```typescript -// Use cn() for class merging -import { cn } from '@mimir-wallet/ui'; -const className = cn("base-classes", conditionalClass, props.className); - -// Leverage component variants -