diff --git a/package.json b/package.json index a62053c47bb..f504c3d93bd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dev:web": "vite", "dev:web:linked": "GENERATE_SOURCEMAP=false BROWSER=none yarn dev:web", "dev:packages": "yarn tsc --build --watch --preserveWatchOutput tsconfig.packages.json", + "dev:swap-widget": "yarn workspace @shapeshiftoss/swap-widget dev", "generate:all": "yarn generate:caip-adapters && yarn run generate:color-map && yarn run generate:asset-data && yarn run generate:tradable-asset-map && yarn run generate:thor-longtail-tokens", "generate:caip-adapters": "yarn workspace @shapeshiftoss/caip generate", "generate:asset-data": "yarn tsx ./scripts/generateAssetData/generateAssetData.ts && yarn run codemod:clear-assets-migration", @@ -321,6 +322,7 @@ "@types/react-dom": "^19.0.0", "readable-stream": "3.6.0", "react-polyglot@^0.7.2": "patch:react-polyglot@npm%3A0.7.2#./.yarn/patches/react-polyglot-npm-0.7.2-636f85156f.patch", - "gridplus-sdk/bs58check": "2.1.2" + "gridplus-sdk/bs58check": "2.1.2", + "@rainbow-me/rainbowkit": "2.1.7" } } diff --git a/packages/swap-widget/Dockerfile b/packages/swap-widget/Dockerfile new file mode 100644 index 00000000000..e2dd3ff8bc2 --- /dev/null +++ b/packages/swap-widget/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-slim AS builder + +WORKDIR /app + +COPY packages/swap-widget ./ + +RUN npm install --legacy-peer-deps +RUN npm run build + +FROM node:20-slim + +RUN npm install -g serve + +WORKDIR /app + +COPY --from=builder /app/dist ./dist + +EXPOSE 3000 + +CMD ["serve", "-s", "dist", "-l", "3000"] diff --git a/packages/swap-widget/README.md b/packages/swap-widget/README.md new file mode 100644 index 00000000000..21152c16677 --- /dev/null +++ b/packages/swap-widget/README.md @@ -0,0 +1,509 @@ +# @shapeshiftoss/swap-widget + +An embeddable React widget that enables multi-chain token swaps using ShapeShift's aggregation API. Integrate swaps into your application with minimal configuration. + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Props Reference](#props-reference) +- [Theming](#theming) +- [Examples](#examples) +- [Exported Types](#exported-types) +- [Exported Utilities](#exported-utilities) +- [Exported Hooks](#exported-hooks) +- [Supported Chains](#supported-chains) +- [Notes and Limitations](#notes-and-limitations) + +## Installation + +```bash +yarn add @shapeshiftoss/swap-widget +# or +npm install @shapeshiftoss/swap-widget +``` + +### Peer Dependencies + +This package requires React 18 or later: + +```json +{ + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } +} +``` + +## Quick Start + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; + +function App() { + return ( + console.log("Success:", txHash)} + onSwapError={(error) => console.error("Error:", error)} + /> + ); +} +``` + +## Props Reference + +### SwapWidgetProps + +| Prop | Type | Default | Description | +| ------------------------ | ----------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | +| `apiKey` | `string` | - | ShapeShift API key for fetching swap rates. Required for production use. | +| `apiBaseUrl` | `string` | - | Custom API base URL. Useful for testing or custom deployments. | +| `defaultSellAsset` | `Asset` | ETH on Ethereum | Initial asset to sell. | +| `defaultBuyAsset` | `Asset` | USDC on Ethereum | Initial asset to buy. | +| `disabledChainIds` | `ChainId[]` | `[]` | Chain IDs to hide from the asset selector. | +| `disabledAssetIds` | `AssetId[]` | `[]` | Asset IDs to hide from the asset selector. | +| `allowedChainIds` | `ChainId[]` | - | If provided, only show assets from these chains. Use this to restrict the widget to specific chains. | +| `allowedAssetIds` | `AssetId[]` | - | If provided, only show these specific assets. | +| `walletClient` | `WalletClient` | - | Viem wallet client for executing EVM transactions. | +| `onConnectWallet` | `() => void` | - | Callback when user clicks "Connect Wallet" button. | +| `onSwapSuccess` | `(txHash: string) => void` | - | Callback when a swap transaction succeeds. | +| `onSwapError` | `(error: Error) => void` | - | Callback when a swap transaction fails. | +| `onAssetSelect` | `(type: "sell" \| "buy", asset: Asset) => void` | - | Callback when user selects an asset. | +| `theme` | `ThemeMode \| ThemeConfig` | `"dark"` | Theme mode (`"light"` or `"dark"`) or full theme configuration. | +| `defaultSlippage` | `string` | `"0.5"` | Default slippage tolerance percentage. | +| `showPoweredBy` | `boolean` | `true` | Show "Powered by ShapeShift" branding. | +| `enableWalletConnection` | `boolean` | `false` | Enable built-in wallet connection UI using RainbowKit. Requires `walletConnectProjectId`. | +| `walletConnectProjectId` | `string` | - | WalletConnect project ID for the built-in wallet connection. Get one at . | +| `defaultReceiveAddress` | `string` | - | Fixed receive address for swaps. When set, users cannot change the receive address. | + +## Theming + +The widget supports both simple theme modes and full customization. + +### Simple Theme Mode + +```tsx + +// or + +``` + +### Custom Theme Configuration + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; +import type { ThemeConfig } from "@shapeshiftoss/swap-widget"; + +const customTheme: ThemeConfig = { + mode: "dark", + accentColor: "#3861fb", // Primary accent color (buttons, focus states) + backgroundColor: "#0a0a14", // Widget background + cardColor: "#12121c", // Card/panel background + textColor: "#ffffff", // Primary text color + borderRadius: "12px", // Border radius for elements + fontFamily: "Inter, sans-serif", +}; + +function App() { + return ; +} +``` + +### ThemeConfig Properties + +| Property | Type | Description | +| ----------------- | ------------------- | -------------------------------------------------- | +| `mode` | `"light" \| "dark"` | Base theme mode. Required. | +| `accentColor` | `string` | Primary accent color for buttons and focus states. | +| `backgroundColor` | `string` | Widget background color. | +| `cardColor` | `string` | Card and panel background color. | +| `textColor` | `string` | Primary text color. | +| `borderRadius` | `string` | Border radius for UI elements. | +| `fontFamily` | `string` | Font family for the widget. | + +## Examples + +### Basic Usage + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; + +function App() { + return ; +} +``` + +### With Wallet Connection (wagmi/viem) + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; +import { useWalletClient } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; + +function App() { + const { data: walletClient } = useWalletClient(); + const { openConnectModal } = useConnectModal(); + + return ( + { + console.log("Swap successful:", txHash); + }} + onSwapError={(error) => { + console.error("Swap failed:", error); + }} + theme={{ + mode: "dark", + accentColor: "#3861fb", + backgroundColor: "#0a0a14", + cardColor: "#12121c", + }} + /> + ); +} +``` + +### With Custom Default Assets + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; +import type { Asset } from "@shapeshiftoss/swap-widget"; + +const defaultSellAsset: Asset = { + assetId: "eip155:137/slip44:966", + chainId: "eip155:137", + symbol: "MATIC", + name: "Polygon", + precision: 18, + icon: "https://example.com/matic.png", +}; + +const defaultBuyAsset: Asset = { + assetId: "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + chainId: "eip155:137", + symbol: "USDC", + name: "USD Coin", + precision: 6, + icon: "https://example.com/usdc.png", +}; + +function App() { + return ( + + ); +} +``` + +### Restricting Available Chains and Assets + +Use `allowedChainIds` to restrict the widget to only show specific chains. This is useful when you want to limit swaps to certain networks. + +```tsx +import { SwapWidget, EVM_CHAIN_IDS } from "@shapeshiftoss/swap-widget"; + +function App() { + return ( + + ); +} +``` + +### With Built-in Wallet Connection + +The widget can manage wallet connections internally using RainbowKit. This is useful when you don't have an existing wallet connection setup. + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; + +function App() { + return ( + + ); +} +``` + +### With Fixed Receive Address + +Use `defaultReceiveAddress` to lock the receive address. When set, users cannot change the destination address. This is useful for integrations where you want all swaps to go to a specific address. + +```tsx +import { SwapWidget } from "@shapeshiftoss/swap-widget"; + +function App() { + return ( + + ); +} +``` + +## Exported Types + +```typescript +import type { + Asset, + AssetId, + ChainId, + Chain, + TradeRate, + TradeQuote, + SwapperName, + SwapWidgetProps, + ThemeMode, + ThemeConfig, +} from "@shapeshiftoss/swap-widget"; +``` + +### Asset + +```typescript +type Asset = { + assetId: AssetId; // CAIP-19 format: "eip155:1/slip44:60" + chainId: ChainId; // CAIP-2 format: "eip155:1" + symbol: string; // e.g., "ETH" + name: string; // e.g., "Ethereum" + precision: number; // e.g., 18 + icon?: string; // URL to asset icon + color?: string; // Brand color + networkName?: string; // Display name for the network + networkIcon?: string; // URL to network icon + explorer?: string; // Block explorer URL + explorerTxLink?: string; // Transaction explorer link template + explorerAddressLink?: string; // Address explorer link template + relatedAssetKey?: AssetId | null; // Related asset for bridged tokens +}; +``` + +### SwapperName + +```typescript +type SwapperName = + | "THORChain" + | "MAYAChain" + | "CoW Swap" + | "0x" + | "Portals" + | "Chainflip" + | "Relay" + | "Bebop" + | "Jupiter" + | "1inch" + | "ButterSwap" + | "ArbitrumBridge"; +``` + +### TradeRate + +```typescript +type TradeRate = { + swapperName: SwapperName; + rate: string; + buyAmountCryptoBaseUnit: string; + sellAmountCryptoBaseUnit: string; + steps: number; + estimatedExecutionTimeMs?: number; + affiliateBps: string; + networkFeeCryptoBaseUnit?: string; + error?: { + code: string; + message: string; + }; + id?: string; +}; +``` + +## Exported Utilities + +```typescript +import { + isEvmChainId, + getEvmChainIdNumber, + getChainType, + formatAmount, + parseAmount, + truncateAddress, + EVM_CHAIN_IDS, + UTXO_CHAIN_IDS, + COSMOS_CHAIN_IDS, + OTHER_CHAIN_IDS, + CHAIN_METADATA, + getChainMeta, + getChainName, + getChainIcon, + getChainColor, +} from "@shapeshiftoss/swap-widget"; +``` + +### Chain Type Utilities + +| Function | Signature | Description | +| --------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------- | +| `isEvmChainId` | `(chainId: string) => boolean` | Check if a chain ID is an EVM chain. | +| `getEvmChainIdNumber` | `(chainId: string) => number` | Extract the numeric chain ID from a CAIP-2 chain ID. | +| `getChainType` | `(chainId: string) => "evm" \| "utxo" \| "cosmos" \| "solana" \| "other"` | Get the chain type from a chain ID. | + +### Amount Formatting + +| Function | Signature | Description | +| ----------------- | -------------------------------------------------------------------- | -------------------------------------------------------- | +| `formatAmount` | `(amount: string, decimals: number, maxDecimals?: number) => string` | Format a base unit amount for display. | +| `parseAmount` | `(amount: string, decimals: number) => string` | Parse a human-readable amount to base units. | +| `truncateAddress` | `(address: string, chars?: number) => string` | Truncate an address for display (e.g., `0x1234...5678`). | + +### Chain Metadata + +| Function | Signature | Description | +| --------------- | ---------------------------------------------- | --------------------------------- | +| `getChainMeta` | `(chainId: ChainId) => ChainMeta \| undefined` | Get full metadata for a chain. | +| `getChainName` | `(chainId: ChainId) => string` | Get the display name for a chain. | +| `getChainIcon` | `(chainId: ChainId) => string \| undefined` | Get the icon URL for a chain. | +| `getChainColor` | `(chainId: ChainId) => string` | Get the brand color for a chain. | + +### Chain ID Constants + +```typescript +const EVM_CHAIN_IDS = { + ethereum: "eip155:1", + arbitrum: "eip155:42161", + arbitrumNova: "eip155:42170", + optimism: "eip155:10", + polygon: "eip155:137", + base: "eip155:8453", + avalanche: "eip155:43114", + bsc: "eip155:56", + gnosis: "eip155:100", +}; + +const UTXO_CHAIN_IDS = { + bitcoin: "bip122:000000000019d6689c085ae165831e93", + bitcoinCash: "bip122:000000000000000000651ef99cb9fcbe", + dogecoin: "bip122:00000000001a91e3dace36e2be3bf030", + litecoin: "bip122:12a765e31ffd4059bada1e25190f6e98", +}; + +const COSMOS_CHAIN_IDS = { + cosmos: "cosmos:cosmoshub-4", + thorchain: "cosmos:thorchain-1", + mayachain: "cosmos:mayachain-mainnet-v1", +}; + +const OTHER_CHAIN_IDS = { + solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", +}; +``` + +## Exported Hooks + +```typescript +import { + useAssets, + useAssetById, + useChains, + useAssetsByChainId, + useAssetSearch, +} from "@shapeshiftoss/swap-widget"; +``` + +| Hook | Return Type | Description | +| --------------------------------- | ------------------------------------------ | -------------------------------------------------------------- | +| `useAssets()` | `{ data: Asset[], isLoading, error, ... }` | Fetch all available assets. | +| `useAssetById(assetId)` | `{ data: Asset \| undefined, ... }` | Fetch a specific asset by ID. | +| `useChains()` | `{ data: ChainInfo[], ... }` | Fetch all available chains with their native assets. | +| `useAssetsByChainId(chainId)` | `{ data: Asset[], ... }` | Fetch all assets for a specific chain. | +| `useAssetSearch(query, chainId?)` | `{ data: Asset[], ... }` | Search assets by symbol or name, optionally filtered by chain. | + +## Supported Chains + +| Chain | Chain ID | Type | +| ----------------- | ----------------------------------------- | ------ | +| Ethereum | `eip155:1` | EVM | +| Arbitrum One | `eip155:42161` | EVM | +| Arbitrum Nova | `eip155:42170` | EVM | +| Optimism | `eip155:10` | EVM | +| Polygon | `eip155:137` | EVM | +| Base | `eip155:8453` | EVM | +| Avalanche C-Chain | `eip155:43114` | EVM | +| BNB Smart Chain | `eip155:56` | EVM | +| Gnosis | `eip155:100` | EVM | +| Bitcoin | `bip122:000000000019d6689c085ae165831e93` | UTXO | +| Bitcoin Cash | `bip122:000000000000000000651ef99cb9fcbe` | UTXO | +| Dogecoin | `bip122:00000000001a91e3dace36e2be3bf030` | UTXO | +| Litecoin | `bip122:12a765e31ffd4059bada1e25190f6e98` | UTXO | +| Cosmos Hub | `cosmos:cosmoshub-4` | Cosmos | +| THORChain | `cosmos:thorchain-1` | Cosmos | +| MAYAChain | `cosmos:mayachain-mainnet-v1` | Cosmos | +| Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | Solana | + +## Notes and Limitations + +### EVM vs Non-EVM Swaps + +- **EVM swaps** (e.g., ETH to USDC, MATIC to WETH) can be executed directly within the widget when a wallet is connected via the `walletClient` prop. +- **Non-EVM swaps** (e.g., BTC to ETH, SOL to USDC) redirect the user to [app.shapeshift.com](https://app.shapeshift.com) to complete the transaction. This is because non-EVM chains require different wallet types. + +### API Key + +An API key is required for fetching swap rates in production. Contact ShapeShift for API access. + +### Internal QueryClient + +The widget manages its own React Query `QueryClient` internally. You do not need to wrap it in a `QueryClientProvider`. + +### Swap Aggregation + +The widget fetches quotes from multiple DEXs and aggregators including: + +- THORChain +- MAYAChain +- CoW Swap +- 0x +- 1inch +- Portals +- Chainflip +- Jupiter (Solana) +- Bebop +- Relay +- ButterSwap +- Arbitrum Bridge + +### Wallet Balance Display + +When a wallet is connected (`walletClient` prop), the widget displays the user's balance for the selected sell and buy assets. This only works for EVM chains where the connected wallet has assets. + +### USD Price Display + +The widget automatically fetches and displays USD prices for selected assets. + +### Mobile Responsive + +The widget is designed to be responsive and works well on mobile devices. diff --git a/packages/swap-widget/index.html b/packages/swap-widget/index.html new file mode 100644 index 00000000000..59ee2cafcb1 --- /dev/null +++ b/packages/swap-widget/index.html @@ -0,0 +1,28 @@ + + + + + + ShapeShift Widget + + + +
+ + + diff --git a/packages/swap-widget/package.json b/packages/swap-widget/package.json new file mode 100644 index 00000000000..fe0941af48f --- /dev/null +++ b/packages/swap-widget/package.json @@ -0,0 +1,42 @@ +{ + "name": "@shapeshiftoss/swap-widget", + "version": "0.0.1", + "private": true, + "description": "Embeddable swap widget using ShapeShift API", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@rainbow-me/rainbowkit": "2.1.7", + "@shapeshiftoss/caip": "npm:@shapeshiftoss/caip@8.16.6", + "@shapeshiftoss/utils": "npm:@shapeshiftoss/utils@1.0.5", + "@tanstack/react-query": "^5.69.0", + "bech32": "^2.0.0", + "p-queue": "^8.0.1", + "react-virtuoso": "^4.7.11", + "viem": "2.40.3", + "wagmi": "^2.9.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.2.2", + "vite": "^5.0.0", + "vite-plugin-node-polyfills": "^0.23.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } +} diff --git a/packages/swap-widget/src/api/client.ts b/packages/swap-widget/src/api/client.ts new file mode 100644 index 00000000000..473b96be29e --- /dev/null +++ b/packages/swap-widget/src/api/client.ts @@ -0,0 +1,95 @@ +import type { AssetId, AssetsResponse, QuoteResponse, RatesResponse } from '../types' + +const DEFAULT_API_BASE_URL = + import.meta.env.VITE_SWAP_WIDGET_API_URL ?? 'https://api.shapeshift.com' + +export type ApiClientConfig = { + baseUrl?: string + apiKey?: string +} + +export const createApiClient = (config: ApiClientConfig = {}) => { + const baseUrl = config.baseUrl ?? DEFAULT_API_BASE_URL + + const fetchWithConfig = async ( + endpoint: string, + params?: Record, + method: 'GET' | 'POST' = 'GET', + timeoutMs = 30000, + ): Promise => { + const url = new URL(`${baseUrl}${endpoint}`) + const headers: Record = { + 'Content-Type': 'application/json', + } + if (config.apiKey) { + headers['x-api-key'] = config.apiKey + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + const fetchOptions: RequestInit = { + headers, + method, + signal: controller.signal, + } + + if (method === 'GET' && params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, value) + }) + } else if (method === 'POST' && params) { + fetchOptions.body = JSON.stringify(params) + } + + const response = await fetch(url.toString(), fetchOptions).finally(() => { + clearTimeout(timeoutId) + }) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() as Promise + } + + return { + getAssets: () => fetchWithConfig('/v1/assets'), + + getRates: (params: { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmountCryptoBaseUnit: string + }) => + fetchWithConfig('/v1/swap/rates', { + sellAssetId: params.sellAssetId, + buyAssetId: params.buyAssetId, + sellAmountCryptoBaseUnit: params.sellAmountCryptoBaseUnit, + }), + + getQuote: (params: { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmountCryptoBaseUnit: string + sendAddress: string + receiveAddress: string + swapperName: string + slippageTolerancePercentageDecimal?: string + }) => + fetchWithConfig( + '/v1/swap/quote', + { + sellAssetId: params.sellAssetId, + buyAssetId: params.buyAssetId, + sellAmountCryptoBaseUnit: params.sellAmountCryptoBaseUnit, + sendAddress: params.sendAddress, + receiveAddress: params.receiveAddress, + swapperName: params.swapperName, + ...(params.slippageTolerancePercentageDecimal && { + slippageTolerancePercentageDecimal: params.slippageTolerancePercentageDecimal, + }), + }, + 'POST', + ), + } +} + +export type ApiClient = ReturnType diff --git a/packages/swap-widget/src/components/AddressInputModal.css b/packages/swap-widget/src/components/AddressInputModal.css new file mode 100644 index 00000000000..7554cdaf533 --- /dev/null +++ b/packages/swap-widget/src/components/AddressInputModal.css @@ -0,0 +1,144 @@ +.ssw-address-modal { + background: var(--ssw-bg-secondary); + border-radius: 16px; + width: 90%; + max-width: 420px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.ssw-address-content { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ssw-address-label { + font-size: 14px; + color: var(--ssw-text-secondary); +} + +.ssw-address-input-wrapper { + display: flex; + align-items: center; + gap: 8px; + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); + border-radius: 12px; + padding: 12px 16px; + transition: border-color 0.15s ease; +} + +.ssw-address-input-wrapper:focus-within { + border-color: var(--ssw-accent); +} + +.ssw-address-input-wrapper.ssw-invalid { + border-color: var(--ssw-error); +} + +.ssw-address-input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--ssw-text-primary); + font-size: 14px; + font-family: monospace; +} + +.ssw-address-input::placeholder { + color: var(--ssw-text-muted); +} + +.ssw-address-clear-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--ssw-text-muted); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; +} + +.ssw-address-clear-btn:hover { + color: var(--ssw-text-primary); + background: var(--ssw-bg-hover); +} + +.ssw-address-error { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--ssw-error); +} + +.ssw-use-wallet-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); + border-radius: 10px; + color: var(--ssw-text-secondary); + font-size: 14px; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-use-wallet-btn:hover { + background: var(--ssw-bg-hover); + color: var(--ssw-text-primary); +} + +.ssw-address-actions { + display: flex; + gap: 12px; + margin-top: 8px; +} + +.ssw-address-btn { + flex: 1; + padding: 14px 16px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-address-btn-secondary { + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); + color: var(--ssw-text-secondary); +} + +.ssw-address-btn-secondary:hover { + background: var(--ssw-bg-hover); + color: var(--ssw-text-primary); +} + +.ssw-address-btn-primary { + background: var(--ssw-accent); + border: none; + color: white; +} + +.ssw-address-btn-primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.ssw-address-btn-primary:disabled { + background: var(--ssw-bg-tertiary); + color: var(--ssw-text-muted); + cursor: not-allowed; +} diff --git a/packages/swap-widget/src/components/AddressInputModal.tsx b/packages/swap-widget/src/components/AddressInputModal.tsx new file mode 100644 index 00000000000..e51d8312710 --- /dev/null +++ b/packages/swap-widget/src/components/AddressInputModal.tsx @@ -0,0 +1,250 @@ +import './AddressInputModal.css' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { ChainId } from '../types' +import { getAddressFormatHint, validateAddress } from '../utils/addressValidation' + +const useLockBodyScroll = (isLocked: boolean) => { + useEffect(() => { + if (!isLocked) return + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, [isLocked]) +} + +type AddressInputModalProps = { + isOpen: boolean + onClose: () => void + chainId: ChainId + chainName: string + currentAddress: string + onAddressChange: (address: string) => void + walletAddress?: string +} + +export const AddressInputModal = ({ + isOpen, + onClose, + chainId, + chainName, + currentAddress, + onAddressChange, + walletAddress, +}: AddressInputModalProps) => { + useLockBodyScroll(isOpen) + const [inputValue, setInputValue] = useState(currentAddress) + const [hasInteracted, setHasInteracted] = useState(false) + const backdropRef = useRef(null) + + useEffect(() => { + if (isOpen) { + setInputValue(currentAddress) + setHasInteracted(false) + backdropRef.current?.focus() + } + }, [isOpen, currentAddress]) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + const validation = useMemo(() => { + if (!inputValue || !hasInteracted) { + return { valid: true, error: undefined } + } + return validateAddress(inputValue, chainId) + }, [inputValue, chainId, hasInteracted]) + + const formatHint = useMemo(() => getAddressFormatHint(chainId), [chainId]) + + const handleInputChange = useCallback((value: string) => { + setInputValue(value) + setHasInteracted(true) + }, []) + + const handleUseWalletAddress = useCallback(() => { + if (walletAddress) { + setInputValue(walletAddress) + setHasInteracted(true) + } + }, [walletAddress]) + + const handleConfirm = useCallback(() => { + const result = validateAddress(inputValue, chainId) + if (result.valid) { + onAddressChange(inputValue) + onClose() + } + }, [inputValue, chainId, onAddressChange, onClose]) + + const handleClear = useCallback(() => { + setInputValue('') + setHasInteracted(false) + onAddressChange('') + onClose() + }, [onAddressChange, onClose]) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + const handleBackdropKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + }, + [onClose], + ) + + const isConfirmDisabled = useMemo(() => { + if (!inputValue) return true + return !validateAddress(inputValue, chainId).valid + }, [inputValue, chainId]) + + if (!isOpen) return null + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+
+
+

+ Receive Address +

+ +
+ +
+
+ Enter {chainName} address +
+ +
+ handleInputChange(e.target.value)} + autoFocus + spellCheck={false} + autoComplete='off' + /> + {inputValue && ( + + )} +
+ + {!validation.valid && hasInteracted && validation.error && ( +
+ + + + + {validation.error} +
+ )} + + {walletAddress && ( + + )} + +
+ + +
+
+
+
+ ) +} diff --git a/packages/swap-widget/src/components/QuoteSelector.css b/packages/swap-widget/src/components/QuoteSelector.css new file mode 100644 index 00000000000..aed09d7c917 --- /dev/null +++ b/packages/swap-widget/src/components/QuoteSelector.css @@ -0,0 +1,146 @@ +.ssw-quote-selector { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ssw-quote-selector:hover { + border-color: var(--ssw-border-hover); + background: var(--ssw-bg-hover); +} + +.ssw-quote-selector.ssw-loading { + cursor: default; + justify-content: center; +} + +.ssw-quote-selector.ssw-loading:hover { + border-color: var(--ssw-border); + background: var(--ssw-bg-tertiary); +} + +.ssw-quote-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--ssw-text-secondary); + font-size: 14px; +} + +.ssw-spinner-small { + width: 16px; + height: 16px; + border: 2px solid var(--ssw-border); + border-top-color: var(--ssw-accent); + border-radius: 50%; + animation: ssw-spin 0.8s linear infinite; +} + +@keyframes ssw-spin { + to { + transform: rotate(360deg); + } +} + +.ssw-quote-left { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} + +.ssw-quote-provider { + display: flex; + align-items: center; + gap: 10px; +} + +.ssw-quote-provider-icon { + width: 28px; + height: 28px; + border-radius: 8px; + object-fit: contain; + background: var(--ssw-bg-secondary); +} + +.ssw-quote-provider-icon-placeholder { + width: 28px; + height: 28px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: white; +} + +.ssw-quote-provider-name { + font-size: 14px; + font-weight: 600; + color: var(--ssw-text-primary); +} + +.ssw-quote-best-tag { + padding: 3px 7px; + border-radius: 5px; + background: var(--ssw-accent); + color: white; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ssw-quote-usd { + font-size: 13px; + color: var(--ssw-text-secondary); + padding-left: 38px; +} + +.ssw-quote-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.ssw-quote-amount-row { + display: flex; + align-items: baseline; + gap: 5px; +} + +.ssw-quote-amount { + font-size: 17px; + font-weight: 600; + color: var(--ssw-text-primary); +} + +.ssw-quote-symbol { + font-size: 13px; + color: var(--ssw-text-secondary); + font-weight: 500; +} + +.ssw-quote-more { + display: flex; + align-items: center; + gap: 3px; + font-size: 12px; + color: var(--ssw-accent); + font-weight: 500; +} + +.ssw-quote-more svg { + opacity: 0.9; +} diff --git a/packages/swap-widget/src/components/QuoteSelector.tsx b/packages/swap-widget/src/components/QuoteSelector.tsx new file mode 100644 index 00000000000..a73e5ac833a --- /dev/null +++ b/packages/swap-widget/src/components/QuoteSelector.tsx @@ -0,0 +1,137 @@ +import './QuoteSelector.css' + +import { useCallback, useMemo, useState } from 'react' + +import { getSwapperColor, getSwapperIcon } from '../constants/swappers' +import { formatUsdValue } from '../hooks/useMarketData' +import type { Asset, TradeRate } from '../types' +import { formatAmount } from '../types' +import { QuotesModal } from './QuotesModal' + +type QuoteSelectorProps = { + rates: TradeRate[] + selectedRate: TradeRate | null + onSelectRate: (rate: TradeRate) => void + buyAsset: Asset + sellAsset: Asset + sellAmountBaseUnit: string + isLoading: boolean + buyAssetUsdPrice?: string +} + +export const QuoteSelector = ({ + rates, + selectedRate, + onSelectRate, + buyAsset, + sellAsset, + sellAmountBaseUnit, + isLoading, + buyAssetUsdPrice, +}: QuoteSelectorProps) => { + const [isModalOpen, setIsModalOpen] = useState(false) + + const bestRate = useMemo(() => rates[0], [rates]) + const alternativeRatesCount = useMemo(() => Math.max(0, rates.length - 1), [rates]) + + const handleOpenModal = useCallback(() => { + if (rates.length > 0) { + setIsModalOpen(true) + } + }, [rates.length]) + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false) + }, []) + + const handleSelectRate = useCallback( + (rate: TradeRate) => { + onSelectRate(rate) + }, + [onSelectRate], + ) + + if (isLoading) { + return ( +
+
+
+ Finding best rates... +
+
+ ) + } + + if (!bestRate) { + return null + } + + const displayRate = selectedRate ?? bestRate + const buyAmount = displayRate.buyAmountCryptoBaseUnit ?? '0' + const swapperIcon = getSwapperIcon(displayRate.swapperName) + const swapperColor = getSwapperColor(displayRate.swapperName) + const formattedBuyAmount = formatAmount(buyAmount, buyAsset.precision) + const usdValue = formatUsdValue(buyAmount, buyAsset.precision, buyAssetUsdPrice) + + return ( + <> + + + + + ) +} diff --git a/packages/swap-widget/src/components/QuotesModal.css b/packages/swap-widget/src/components/QuotesModal.css new file mode 100644 index 00000000000..3234bde1318 --- /dev/null +++ b/packages/swap-widget/src/components/QuotesModal.css @@ -0,0 +1,250 @@ +.ssw-quotes-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: ssw-fade-in 0.15s ease; +} + +@keyframes ssw-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.ssw-quotes-modal { + width: 100%; + max-width: 400px; + max-height: 70vh; + background: var(--ssw-bg-secondary, #12121c); + border-radius: 20px; + border: 1px solid var(--ssw-border, rgba(255, 255, 255, 0.08)); + display: flex; + flex-direction: column; + overflow: hidden; + animation: ssw-slide-up 0.2s ease; +} + +@keyframes ssw-slide-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.ssw-quotes-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 20px 20px 16px; + flex-shrink: 0; +} + +.ssw-quotes-header-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ssw-quotes-modal-title { + font-size: 18px; + font-weight: 600; + color: var(--ssw-text-primary, #ffffff); + margin: 0; +} + +.ssw-quotes-modal-subtitle { + font-size: 13px; + color: var(--ssw-text-secondary, #a0a0b0); +} + +.ssw-quotes-modal-close { + padding: 6px; + border: none; + background: none; + border-radius: 8px; + color: var(--ssw-text-muted, #6b6b80); + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + margin: -2px -6px 0 0; +} + +.ssw-quotes-modal-close:hover { + color: var(--ssw-text-primary, #ffffff); + background: var(--ssw-bg-hover, rgba(255, 255, 255, 0.05)); +} + +.ssw-quotes-modal-list { + flex: 1; + overflow-y: auto; + padding: 0 12px 12px; + display: flex; + flex-direction: column; + gap: 6px; + scrollbar-width: thin; + scrollbar-color: var(--ssw-border) transparent; +} + +.ssw-quotes-modal-list::-webkit-scrollbar { + width: 6px; +} + +.ssw-quotes-modal-list::-webkit-scrollbar-track { + background: transparent; +} + +.ssw-quotes-modal-list::-webkit-scrollbar-thumb { + background: var(--ssw-border); + border-radius: 3px; +} + +.ssw-quotes-modal-list::-webkit-scrollbar-thumb:hover { + background: var(--ssw-border-hover); +} + +.ssw-quote-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid transparent; + border-radius: 14px; + background: var(--ssw-bg-tertiary, #1a1a2e); + cursor: pointer; + transition: all 0.15s ease; + text-align: left; + width: 100%; + gap: 12px; +} + +.ssw-quote-row:hover { + background: var(--ssw-bg-hover, rgba(255, 255, 255, 0.05)); +} + +.ssw-quote-row.ssw-best { + border-color: var(--ssw-accent, #3861fb); + background: rgba(56, 97, 251, 0.06); +} + +.ssw-quote-row.ssw-best:hover { + background: rgba(56, 97, 251, 0.1); +} + +.ssw-quote-row.ssw-selected { + border-color: var(--ssw-success, #00d395); + background: rgba(0, 211, 149, 0.06); +} + +.ssw-quote-row-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.ssw-quote-row-icon { + width: 36px; + height: 36px; + border-radius: 10px; + object-fit: contain; + background: var(--ssw-bg-secondary, #12121c); + flex-shrink: 0; +} + +.ssw-quote-row-icon-placeholder { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 600; + color: white; + flex-shrink: 0; +} + +.ssw-quote-row-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ssw-quote-row-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ssw-quote-row-name { + font-size: 14px; + font-weight: 600; + color: var(--ssw-text-primary, #ffffff); +} + +.ssw-quote-row-best { + padding: 2px 6px; + border-radius: 4px; + background: var(--ssw-accent, #3861fb); + color: white; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ssw-quote-row-diff { + padding: 2px 6px; + border-radius: 4px; + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + font-size: 10px; + font-weight: 600; +} + +.ssw-quote-row-time { + font-size: 12px; + color: var(--ssw-text-muted, #6b6b80); +} + +.ssw-quote-row-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.ssw-quote-row-amount { + font-size: 15px; + font-weight: 600; + color: var(--ssw-text-primary, #ffffff); +} + +.ssw-quote-row-symbol { + font-size: 13px; + font-weight: 500; + color: var(--ssw-text-secondary, #a0a0b0); +} + +.ssw-quote-row-usd { + font-size: 12px; + color: var(--ssw-text-muted, #6b6b80); +} diff --git a/packages/swap-widget/src/components/QuotesModal.tsx b/packages/swap-widget/src/components/QuotesModal.tsx new file mode 100644 index 00000000000..6ded1103ebc --- /dev/null +++ b/packages/swap-widget/src/components/QuotesModal.tsx @@ -0,0 +1,182 @@ +import './QuotesModal.css' + +import { bnOrZero } from '@shapeshiftoss/utils' +import { useCallback, useEffect, useMemo } from 'react' + +import { getSwapperColor, getSwapperIcon } from '../constants/swappers' +import { formatUsdValue } from '../hooks/useMarketData' +import type { Asset, TradeRate } from '../types' +import { formatAmount } from '../types' + +const useLockBodyScroll = (isLocked: boolean) => { + useEffect(() => { + if (!isLocked) return + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, [isLocked]) +} + +type QuotesModalProps = { + isOpen: boolean + onClose: () => void + rates: TradeRate[] + selectedRate: TradeRate | null + onSelectRate: (rate: TradeRate) => void + buyAsset: Asset + sellAsset: Asset + sellAmountBaseUnit: string + buyAssetUsdPrice?: string +} + +const calculateSavingsPercent = (bestAmount: string, currentAmount: string): string | null => { + const best = bnOrZero(bestAmount) + const current = bnOrZero(currentAmount) + if (best.isZero()) return null + const diff = best.minus(current).div(best).times(100) + return diff.gt(0.1) ? diff.toFixed(2) : null +} + +export const QuotesModal = ({ + isOpen, + onClose, + rates, + selectedRate, + onSelectRate, + buyAsset, + sellAsset, + sellAmountBaseUnit, + buyAssetUsdPrice, +}: QuotesModalProps) => { + useLockBodyScroll(isOpen) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + const handleSelectRate = useCallback( + (rate: TradeRate) => { + onSelectRate(rate) + onClose() + }, + [onSelectRate, onClose], + ) + + const sortedRates = useMemo(() => { + return [...rates] + .filter(r => !r.error && r.buyAmountCryptoBaseUnit !== '0') + .sort((a, b) => { + const aAmount = bnOrZero(a.buyAmountCryptoBaseUnit) + const bAmount = bnOrZero(b.buyAmountCryptoBaseUnit) + return bAmount.minus(aAmount).toNumber() + }) + }, [rates]) + + const bestRate = useMemo(() => sortedRates[0], [sortedRates]) + const bestBuyAmount = bestRate?.buyAmountCryptoBaseUnit ?? '0' + + if (!isOpen) return null + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.key === 'Escape' && onClose()} + role='dialog' + aria-modal='true' + aria-labelledby='quotes-modal-title' + > +
+
+
+

+ Select Route +

+ + {formatAmount(sellAmountBaseUnit, sellAsset.precision)} {sellAsset.symbol} →{' '} + {buyAsset.symbol} + +
+ +
+ +
+ {sortedRates.map((rate, index) => { + const buyAmount = rate.buyAmountCryptoBaseUnit ?? '0' + const estimatedTime = rate.estimatedExecutionTimeMs + const isBest = index === 0 + const isSelected = selectedRate?.id === rate.id + const swapperIcon = getSwapperIcon(rate.swapperName) + const swapperColor = getSwapperColor(rate.swapperName) + const formattedBuyAmount = formatAmount(buyAmount, buyAsset.precision) + const usdValue = formatUsdValue(buyAmount, buyAsset.precision, buyAssetUsdPrice) + const savingsPercent = isBest ? null : calculateSavingsPercent(bestBuyAmount, buyAmount) + const estimatedSeconds = estimatedTime ? Math.round(estimatedTime / 1000) : 0 + const hasTime = estimatedSeconds > 0 + + return ( + + ) + })} +
+
+
+ ) +} diff --git a/packages/swap-widget/src/components/SettingsModal.css b/packages/swap-widget/src/components/SettingsModal.css new file mode 100644 index 00000000000..2b059a14f93 --- /dev/null +++ b/packages/swap-widget/src/components/SettingsModal.css @@ -0,0 +1,133 @@ +.ssw-settings-modal { + background: var(--ssw-bg-secondary); + border-radius: 16px; + width: 100%; + max-width: 400px; + overflow: hidden; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); +} + +.ssw-settings-content { + padding: 20px 24px 24px; +} + +.ssw-settings-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ssw-settings-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--ssw-text-secondary); +} + +.ssw-info-btn { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--ssw-text-muted); + border-radius: 50%; + transition: color 0.15s ease; +} + +.ssw-info-btn:hover { + color: var(--ssw-text-secondary); +} + +.ssw-slippage-options { + display: flex; + gap: 8px; +} + +.ssw-slippage-btn { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--ssw-border); + border-radius: 10px; + background: var(--ssw-bg-tertiary); + color: var(--ssw-text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-slippage-btn:hover { + border-color: var(--ssw-accent); +} + +.ssw-slippage-btn.ssw-selected { + background: var(--ssw-accent-light); + border-color: var(--ssw-accent); + color: var(--ssw-accent); +} + +.ssw-slippage-custom { + flex: 1.2; + position: relative; + display: flex; + align-items: center; +} + +.ssw-slippage-custom input { + width: 100%; + padding: 10px 28px 10px 12px; + border: 1px solid var(--ssw-border); + border-radius: 10px; + background: var(--ssw-bg-tertiary); + color: var(--ssw-text-primary); + font-size: 14px; + font-weight: 500; + outline: none; + transition: border-color 0.15s ease; +} + +.ssw-slippage-custom input:focus { + border-color: var(--ssw-accent); +} + +.ssw-slippage-custom input::placeholder { + color: var(--ssw-text-muted); + font-weight: 400; +} + +.ssw-slippage-custom.ssw-selected input { + background: var(--ssw-accent-light); + border-color: var(--ssw-accent); +} + +.ssw-slippage-suffix { + position: absolute; + right: 12px; + color: var(--ssw-text-secondary); + font-size: 14px; + pointer-events: none; +} + +.ssw-slippage-warning { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: 10px; + background: rgba(255, 193, 7, 0.1); + color: #ffc107; + font-size: 13px; + line-height: 1.4; +} + +.ssw-slippage-warning svg { + flex-shrink: 0; + margin-top: 1px; +} + +.ssw-slippage-warning.ssw-error { + background: rgba(244, 67, 54, 0.1); + color: #f44336; +} diff --git a/packages/swap-widget/src/components/SettingsModal.tsx b/packages/swap-widget/src/components/SettingsModal.tsx new file mode 100644 index 00000000000..07911019821 --- /dev/null +++ b/packages/swap-widget/src/components/SettingsModal.tsx @@ -0,0 +1,180 @@ +import './SettingsModal.css' + +import { useCallback, useEffect, useState } from 'react' + +const SLIPPAGE_PRESETS = ['0.1', '0.5', '1.0'] + +const useLockBodyScroll = (isLocked: boolean) => { + useEffect(() => { + if (!isLocked) return + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, [isLocked]) +} + +type SettingsModalProps = { + isOpen: boolean + onClose: () => void + slippage: string + onSlippageChange: (slippage: string) => void +} + +export const SettingsModal = ({ + isOpen, + onClose, + slippage, + onSlippageChange, +}: SettingsModalProps) => { + useLockBodyScroll(isOpen) + const [customSlippage, setCustomSlippage] = useState('') + const [isCustom, setIsCustom] = useState(!SLIPPAGE_PRESETS.includes(slippage)) + + const handlePresetClick = useCallback( + (preset: string) => { + setIsCustom(false) + setCustomSlippage('') + onSlippageChange(preset) + }, + [onSlippageChange], + ) + + const handleCustomChange = useCallback( + (value: string) => { + const sanitized = value.replace(/[^0-9.]/g, '') + const parts = sanitized.split('.') + const formatted = parts.length > 2 ? `${parts[0]}.${parts.slice(1).join('')}` : sanitized + + setCustomSlippage(formatted) + setIsCustom(true) + + const numValue = parseFloat(formatted) + if (!isNaN(numValue) && numValue > 0 && numValue <= 50) { + onSlippageChange(formatted) + } + }, + [onSlippageChange], + ) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + if (!isOpen) return null + + const currentSlippageNum = parseFloat(slippage) + const isHighSlippage = currentSlippageNum > 1 + const isVeryHighSlippage = currentSlippageNum > 5 + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.key === 'Escape' && onClose()} + role='dialog' + aria-modal='true' + aria-labelledby='settings-modal-title' + > +
+
+

+ Settings +

+ +
+ +
+
+
+ Slippage Tolerance + +
+ +
+ {SLIPPAGE_PRESETS.map(preset => ( + + ))} +
+ handleCustomChange(e.target.value)} + onFocus={() => { + setIsCustom(true) + if (!customSlippage) setCustomSlippage(slippage) + }} + /> + % +
+
+ + {isHighSlippage && ( +
+ + + + + {isVeryHighSlippage + ? 'Very high slippage. Your transaction may be frontrun.' + : 'High slippage may result in unfavorable rates.'} + +
+ )} +
+
+
+
+ ) +} diff --git a/packages/swap-widget/src/components/SwapWidget.css b/packages/swap-widget/src/components/SwapWidget.css new file mode 100644 index 00000000000..2ab0dd5056a --- /dev/null +++ b/packages/swap-widget/src/components/SwapWidget.css @@ -0,0 +1,513 @@ +.ssw-widget { + --ssw-accent: #3861fb; + --ssw-accent-light: rgba(56, 97, 251, 0.1); + --ssw-success: #00d395; + --ssw-error: #f44336; + --ssw-warning: #ffc107; + + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + width: 100%; + max-width: 420px; + border-radius: 20px; + overflow: hidden; +} + +.ssw-widget *, +.ssw-widget *::before, +.ssw-widget *::after { + box-sizing: border-box; +} + +.ssw-widget.ssw-dark { + --ssw-bg-primary: #0a0a14; + --ssw-bg-secondary: #12121c; + --ssw-bg-tertiary: #1a1a2e; + --ssw-bg-input: #0d0d16; + --ssw-bg-hover: rgba(255, 255, 255, 0.05); + --ssw-border: rgba(255, 255, 255, 0.08); + --ssw-border-hover: rgba(255, 255, 255, 0.15); + --ssw-text-primary: #ffffff; + --ssw-text-secondary: #a0a0b0; + --ssw-text-muted: #6b6b80; + + background: var(--ssw-bg-secondary); + color: var(--ssw-text-primary); +} + +.ssw-widget.ssw-light { + --ssw-bg-primary: #ffffff; + --ssw-bg-secondary: #f8f9fc; + --ssw-bg-tertiary: #ffffff; + --ssw-bg-input: #f0f2f5; + --ssw-bg-hover: rgba(0, 0, 0, 0.04); + --ssw-border: rgba(0, 0, 0, 0.08); + --ssw-border-hover: rgba(0, 0, 0, 0.15); + --ssw-text-primary: #1a1a2e; + --ssw-text-secondary: #5c5c70; + --ssw-text-muted: #9090a0; + + background: var(--ssw-bg-secondary); + color: var(--ssw-text-primary); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +} + +.ssw-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--ssw-border); +} + +.ssw-header-title { + font-size: 16px; + font-weight: 600; + color: var(--ssw-text-primary); +} + +.ssw-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.ssw-connect-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid var(--ssw-border); + background: var(--ssw-bg-tertiary); + border-radius: 10px; + color: var(--ssw-text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-connect-btn:hover { + background: var(--ssw-bg-hover); + border-color: var(--ssw-accent); +} + +.ssw-connect-btn.ssw-connected { + background: color-mix(in srgb, var(--ssw-accent) 15%, var(--ssw-bg-tertiary)); + border-color: color-mix(in srgb, var(--ssw-accent) 40%, transparent); +} + +.ssw-connect-btn.ssw-wrong-network { + background: color-mix(in srgb, var(--ssw-error) 15%, var(--ssw-bg-tertiary)); + border-color: var(--ssw-error); + color: var(--ssw-error); +} + +.ssw-settings-btn { + padding: 8px; + border: none; + background: none; + border-radius: 10px; + color: var(--ssw-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-settings-btn:hover { + color: var(--ssw-text-primary); + background: var(--ssw-bg-hover); +} + +.ssw-swap-container { + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; + position: relative; +} + +.ssw-token-section { + background: var(--ssw-bg-tertiary); + border-radius: 16px; + padding: 16px; + border: 1px solid var(--ssw-border); + transition: border-color 0.15s ease; +} + +.ssw-token-section:focus-within { + border-color: var(--ssw-accent); +} + +.ssw-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.ssw-section-label { + font-size: 13px; + font-weight: 500; + color: var(--ssw-text-secondary); +} + +.ssw-wallet-badge { + font-size: 12px; + color: var(--ssw-accent); + padding: 2px 8px; + background: var(--ssw-accent-light); + border-radius: 6px; +} + +.ssw-input-row { + display: flex; + align-items: center; + gap: 12px; +} + +.ssw-amount-input { + flex: 1; + background: none; + border: none; + font-size: 32px; + font-weight: 500; + color: var(--ssw-text-primary); + outline: none; + min-width: 0; +} + +.ssw-amount-input::placeholder { + color: var(--ssw-text-muted); +} + +.ssw-token-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: none; + background: var(--ssw-bg-secondary); + border-radius: 12px; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ssw-token-btn:hover { + background: var(--ssw-bg-hover); +} + +.ssw-token-icon { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: contain; +} + +.ssw-token-icon-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--ssw-accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: white; +} + +.ssw-token-info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.ssw-token-symbol { + font-size: 15px; + font-weight: 600; + color: var(--ssw-text-primary); +} + +.ssw-token-chain { + font-size: 12px; + color: var(--ssw-text-secondary); +} + +.ssw-token-btn svg { + color: var(--ssw-text-muted); +} + +.ssw-section-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.ssw-balance { + font-size: 12px; + color: var(--ssw-text-muted); +} + +.ssw-balance-skeleton { + width: 80px; + height: 14px; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--ssw-bg-hover) 25%, + var(--ssw-border) 50%, + var(--ssw-bg-hover) 75% + ); + background-size: 200% 100%; + animation: ssw-skeleton-shimmer 1.5s infinite; +} + +@keyframes ssw-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.ssw-usd-value { + font-size: 13px; + color: var(--ssw-text-muted); +} + +.ssw-swap-divider { + display: flex; + justify-content: center; + margin: -12px 0; + position: relative; + z-index: 10; +} + +.ssw-swap-btn { + width: 36px; + height: 36px; + border: 4px solid var(--ssw-bg-secondary); + background: var(--ssw-bg-tertiary); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--ssw-text-secondary); + transition: all 0.15s ease; +} + +.ssw-swap-btn:hover { + color: var(--ssw-text-primary); + background: var(--ssw-bg-hover); +} + +.ssw-receive-address-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-receive-address-btn:hover { + background: var(--ssw-bg-hover); + border-color: var(--ssw-accent); +} + +.ssw-receive-address-btn.ssw-custom { + border-color: var(--ssw-accent); + background: color-mix(in srgb, var(--ssw-accent) 10%, var(--ssw-bg-tertiary)); +} + +.ssw-receive-address-text { + font-size: 12px; + font-family: monospace; + color: var(--ssw-accent); +} + +.ssw-receive-address-btn svg { + color: var(--ssw-text-muted); + flex-shrink: 0; +} + +.ssw-receive-address-btn:hover svg { + color: var(--ssw-accent); +} + +.ssw-quotes { + padding: 0 16px 16px; +} + +.ssw-action-btn { + width: calc(100% - 32px); + margin: 0 16px 16px; + padding: 16px; + border: none; + border-radius: 14px; + background: var(--ssw-accent); + color: white; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.ssw-action-btn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.ssw-action-btn:disabled { + background: var(--ssw-bg-tertiary); + color: var(--ssw-text-muted); + cursor: not-allowed; +} + +.ssw-action-btn.ssw-secondary { + background: color-mix(in srgb, var(--ssw-accent) 15%, var(--ssw-bg-tertiary)); + color: var(--ssw-text-primary); + border: 1px solid color-mix(in srgb, var(--ssw-accent) 40%, transparent); +} + +.ssw-action-btn.ssw-secondary:hover:not(:disabled) { + background: color-mix(in srgb, var(--ssw-accent) 25%, var(--ssw-bg-tertiary)); + filter: none; +} + +.ssw-powered-by { + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + color: var(--ssw-text-muted); + border-top: 1px solid var(--ssw-border); +} + +.ssw-powered-by a { + color: var(--ssw-accent); + text-decoration: none; + font-weight: 500; +} + +.ssw-powered-by-link { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ssw-powered-by a:hover { + opacity: 0.8; +} + +/* Transaction Status */ +.ssw-tx-status { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 0 16px 16px; + border-radius: 12px; + background: var(--ssw-bg-tertiary); + border: 1px solid var(--ssw-border); +} + +.ssw-tx-status-pending { + border-color: var(--ssw-accent); + background: var(--ssw-accent-light); +} + +.ssw-tx-status-success { + border-color: var(--ssw-success); + background: rgba(0, 211, 149, 0.1); +} + +.ssw-tx-status-error { + border-color: var(--ssw-error); + background: rgba(244, 67, 54, 0.1); +} + +.ssw-tx-status-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.ssw-tx-status-pending .ssw-tx-status-icon { + color: var(--ssw-accent); +} + +.ssw-tx-status-success .ssw-tx-status-icon { + color: var(--ssw-success); +} + +.ssw-tx-status-error .ssw-tx-status-icon { + color: var(--ssw-error); +} + +.ssw-tx-status-content { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.ssw-tx-status-message { + font-size: 14px; + font-weight: 500; + color: var(--ssw-text-primary); + word-break: break-word; +} + +.ssw-tx-status-link { + font-size: 13px; + color: var(--ssw-accent); + text-decoration: none; +} + +.ssw-tx-status-link:hover { + text-decoration: underline; +} + +.ssw-tx-status-close { + flex-shrink: 0; + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--ssw-text-muted); + transition: color 0.15s ease; +} + +.ssw-tx-status-close:hover { + color: var(--ssw-text-primary); +} + +/* Spinner animation */ +@keyframes ssw-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.ssw-spinner { + animation: ssw-spin 1s linear infinite; +} diff --git a/packages/swap-widget/src/components/SwapWidget.tsx b/packages/swap-widget/src/components/SwapWidget.tsx new file mode 100644 index 00000000000..6d95a1c93e8 --- /dev/null +++ b/packages/swap-widget/src/components/SwapWidget.tsx @@ -0,0 +1,883 @@ +import './SwapWidget.css' + +import { ethChainId, usdcAssetId } from '@shapeshiftoss/caip' +import { ethereum } from '@shapeshiftoss/utils' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' +import type { Chain, WalletClient } from 'viem' +import { createPublicClient, encodeFunctionData, http } from 'viem' +import { + arbitrum, + arbitrumNova, + avalanche, + base, + bsc, + gnosis, + hyperEvm, + katana, + mainnet, + monad, + optimism, + plasma, + polygon, +} from 'viem/chains' + +import { createApiClient } from '../api/client' +import { getBaseAsset } from '../constants/chains' +import { useChainInfo } from '../hooks/useAssets' +import { useAssetBalance } from '../hooks/useBalances' +import { formatUsdValue, useMarketData } from '../hooks/useMarketData' +import { useSwapRates } from '../hooks/useSwapRates' +import type { Asset, SwapWidgetProps, ThemeMode, TradeRate } from '../types' +import { formatAmount, getChainType, getEvmNetworkId, parseAmount, truncateAddress } from '../types' +import { AddressInputModal } from './AddressInputModal' +import { QuoteSelector } from './QuoteSelector' +import { SettingsModal } from './SettingsModal' +import { TokenSelectModal } from './TokenSelectModal' +import { ConnectWalletButton, InternalWalletProvider } from './WalletProvider' + +const VIEM_CHAINS_BY_ID: Record = { + 1: mainnet, + 10: optimism, + 56: bsc, + 100: gnosis, + 137: polygon, + 143: monad, + 999: hyperEvm, + 8453: base, + 9745: plasma, + 42161: arbitrum, + 42170: arbitrumNova, + 43114: avalanche, + 747474: katana, +} + +const addChainToWallet = async (client: WalletClient, chain: Chain): Promise => { + const { id, name, nativeCurrency, rpcUrls, blockExplorers } = chain + + await client.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: `0x${id.toString(16)}`, + chainName: name, + nativeCurrency, + rpcUrls: rpcUrls.default.http, + blockExplorerUrls: blockExplorers?.default ? [blockExplorers.default.url] : undefined, + }, + ], + }) +} + +const switchOrAddChain = async (client: WalletClient, chainId: number): Promise => { + const chain = VIEM_CHAINS_BY_ID[chainId] + + try { + await client.switchChain({ id: chainId }) + } catch (error) { + const switchError = error as { code?: number; message?: string } + const isChainNotAddedError = + switchError.code === 4902 || + switchError.message?.toLowerCase().includes('unrecognized chain') || + switchError.message?.toLowerCase().includes('chain not added') || + switchError.message?.toLowerCase().includes('try adding the chain') + + if (isChainNotAddedError && chain) { + await addChainToWallet(client, chain) + await client.switchChain({ id: chainId }) + } else { + throw error + } + } +} + +const DEFAULT_SELL_ASSET: Asset = { + assetId: ethereum.assetId, + chainId: ethereum.chainId, + symbol: ethereum.symbol, + name: ethereum.name, + precision: ethereum.precision, + icon: ethereum.icon, + networkName: ethereum.networkName, + explorer: ethereum.explorer, + explorerTxLink: ethereum.explorerTxLink, + explorerAddressLink: ethereum.explorerAddressLink, +} + +const DEFAULT_BUY_ASSET: Asset = { + assetId: usdcAssetId, + chainId: ethChainId, + symbol: 'USDC', + name: 'USD Coin', + precision: 6, + icon: 'https://assets.coingecko.com/coins/images/6319/standard/usdc.png', +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}) + +type SwapWidgetInnerProps = SwapWidgetProps & { + apiClient: ReturnType +} + +type SwapWidgetCoreProps = SwapWidgetInnerProps & { + enableWalletConnection?: boolean +} + +const SwapWidgetCore = ({ + defaultSellAsset = DEFAULT_SELL_ASSET, + defaultBuyAsset = DEFAULT_BUY_ASSET, + disabledChainIds = [], + disabledAssetIds = [], + allowedChainIds, + walletClient, + onConnectWallet, + onSwapSuccess, + onSwapError, + onAssetSelect, + theme = 'dark', + defaultSlippage = '0.5', + showPoweredBy = true, + defaultReceiveAddress, + enableWalletConnection = false, + apiClient, +}: SwapWidgetCoreProps) => { + const [sellAsset, setSellAsset] = useState(defaultSellAsset) + const [buyAsset, setBuyAsset] = useState(defaultBuyAsset) + const [sellAmount, setSellAmount] = useState('') + const [selectedRate, setSelectedRate] = useState(null) + const [slippage, setSlippage] = useState(defaultSlippage) + const [isExecuting, setIsExecuting] = useState(false) + const [txStatus, setTxStatus] = useState<{ + status: 'pending' | 'success' | 'error' + txHash?: string + message?: string + } | null>(null) + + const [tokenModalType, setTokenModalType] = useState<'sell' | 'buy' | null>(null) + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [isAddressModalOpen, setIsAddressModalOpen] = useState(false) + const [customReceiveAddress, setCustomReceiveAddress] = useState('') + + const themeMode: ThemeMode = typeof theme === 'string' ? theme : theme.mode + const themeConfig = typeof theme === 'object' ? theme : undefined + + const sellAmountBaseUnit = useMemo( + () => (sellAmount ? parseAmount(sellAmount, sellAsset.precision) : undefined), + [sellAmount, sellAsset.precision], + ) + + const isSellAssetEvm = getChainType(sellAsset.chainId) === 'evm' + const isBuyAssetEvm = getChainType(buyAsset.chainId) === 'evm' + const canExecuteDirectly = isSellAssetEvm && isBuyAssetEvm + + const { + data: rates, + isLoading: isLoadingRates, + error: ratesError, + } = useSwapRates(apiClient, { + sellAssetId: sellAsset.assetId, + buyAssetId: buyAsset.assetId, + sellAmountCryptoBaseUnit: sellAmountBaseUnit, + enabled: !!sellAmountBaseUnit && sellAmountBaseUnit !== '0' && isSellAssetEvm, + }) + + const walletAddress = useMemo(() => { + if (!walletClient) return undefined + return (walletClient as WalletClient).account?.address + }, [walletClient]) + + const effectiveReceiveAddress = useMemo(() => { + return customReceiveAddress || defaultReceiveAddress || walletAddress || '' + }, [customReceiveAddress, defaultReceiveAddress, walletAddress]) + + const isCustomAddress = useMemo(() => { + return !!customReceiveAddress && customReceiveAddress !== walletAddress + }, [customReceiveAddress, walletAddress]) + + const { + data: sellAssetBalance, + isLoading: isSellBalanceLoading, + refetch: refetchSellBalance, + } = useAssetBalance(walletAddress, sellAsset.assetId, sellAsset.precision) + const { + data: buyAssetBalance, + isLoading: isBuyBalanceLoading, + refetch: refetchBuyBalance, + } = useAssetBalance(walletAddress, buyAsset.assetId, buyAsset.precision) + + const handleSwapTokens = useCallback(() => { + const tempSell = sellAsset + setSellAsset(buyAsset) + setBuyAsset(tempSell) + setSellAmount('') + setSelectedRate(null) + }, [sellAsset, buyAsset]) + + const handleSellAssetSelect = useCallback( + (asset: Asset) => { + setSellAsset(asset) + setSelectedRate(null) + onAssetSelect?.('sell', asset) + }, + [onAssetSelect], + ) + + const handleBuyAssetSelect = useCallback( + (asset: Asset) => { + setBuyAsset(asset) + setSelectedRate(null) + onAssetSelect?.('buy', asset) + }, + [onAssetSelect], + ) + + const handleExecuteSwap = useCallback(async () => { + if (!isSellAssetEvm) { + const params = new URLSearchParams({ + sellAssetId: sellAsset.assetId, + buyAssetId: buyAsset.assetId, + sellAmount, + }) + window.open(`https://app.shapeshift.com/trade?${params.toString()}`, '_blank') + return + } + + const rateToUse = selectedRate ?? rates?.[0] + if (!rateToUse || !walletClient || !walletAddress) return + + if (!canExecuteDirectly) { + const params = new URLSearchParams({ + sellAssetId: sellAsset.assetId, + buyAssetId: buyAsset.assetId, + sellAmount, + }) + window.open(`https://app.shapeshift.com/trade?${params.toString()}`, '_blank') + return + } + + setIsExecuting(true) + + try { + const requiredChainId = getEvmNetworkId(sellAsset.chainId) + const client = walletClient as WalletClient + + const currentChainId = await client.getChainId() + if (currentChainId !== requiredChainId) { + await switchOrAddChain(client, requiredChainId) + } + + if (!sellAmountBaseUnit) { + throw new Error('Sell amount is required') + } + + const slippageDecimal = (parseFloat(slippage) / 100).toString() + const quoteResponse = await apiClient.getQuote({ + sellAssetId: sellAsset.assetId, + buyAssetId: buyAsset.assetId, + sellAmountCryptoBaseUnit: sellAmountBaseUnit, + sendAddress: walletAddress, + receiveAddress: effectiveReceiveAddress || walletAddress, + swapperName: rateToUse.swapperName, + slippageTolerancePercentageDecimal: slippageDecimal, + }) + + const baseAsset = getBaseAsset(sellAsset.chainId) + const nativeCurrency = baseAsset + ? { + name: baseAsset.name, + symbol: baseAsset.symbol, + decimals: baseAsset.precision, + } + : { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + } + + const viemChain = VIEM_CHAINS_BY_ID[requiredChainId] + const chain = viemChain ?? { + id: requiredChainId, + name: baseAsset?.networkName ?? baseAsset?.name ?? 'Chain', + nativeCurrency, + rpcUrls: { default: { http: [] } }, + } + + if (quoteResponse.approval?.isRequired) { + const sellAssetAddress = sellAsset.assetId.split('/')[1]?.split(':')[1] + if (sellAssetAddress) { + const approvalData = encodeFunctionData({ + abi: [ + { + name: 'approve', + type: 'function', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + ], + functionName: 'approve', + args: [quoteResponse.approval.spender as `0x${string}`, BigInt(sellAmountBaseUnit)], + }) + + const approvalHash = await client.sendTransaction({ + to: sellAssetAddress as `0x${string}`, + data: approvalData, + value: BigInt(0), + chain, + account: walletAddress as `0x${string}`, + }) + + const publicClient = createPublicClient({ + chain, + transport: http(), + }) + await publicClient.waitForTransactionReceipt({ hash: approvalHash }) + } + } + + const outerStep = quoteResponse.steps?.[0] + const innerStep = quoteResponse.quote?.steps?.[0] + + const transactionData = + quoteResponse.transactionData ?? + outerStep?.transactionData ?? + outerStep?.relayTransactionMetadata ?? + outerStep?.butterSwapTransactionMetadata ?? + innerStep?.transactionData ?? + innerStep?.relayTransactionMetadata ?? + innerStep?.butterSwapTransactionMetadata + + if (!transactionData) { + throw new Error( + `No transaction data returned. Response keys: ${Object.keys(quoteResponse).join(', ')}`, + ) + } + + const to = transactionData.to as string + const data = transactionData.data as string + const value = transactionData.value ?? '0' + const gasLimit = transactionData.gasLimit as string | undefined + + setTxStatus({ + status: 'pending', + message: 'Waiting for confirmation...', + }) + + const txHash = await client.sendTransaction({ + to: to as `0x${string}`, + data: data as `0x${string}`, + value: BigInt(value), + gas: gasLimit ? BigInt(gasLimit) : undefined, + chain, + account: walletAddress as `0x${string}`, + }) + + setTxStatus({ status: 'success', txHash, message: 'Swap successful!' }) + onSwapSuccess?.(txHash) + + setSellAmount('') + setSelectedRate(null) + + setTimeout(() => { + refetchSellBalance?.() + refetchBuyBalance?.() + }, 3000) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Transaction failed' + setTxStatus({ status: 'error', message: errorMessage }) + onSwapError?.(error as Error) + } finally { + setIsExecuting(false) + } + }, [ + selectedRate, + rates, + walletClient, + walletAddress, + effectiveReceiveAddress, + canExecuteDirectly, + isSellAssetEvm, + sellAsset, + buyAsset, + sellAmount, + sellAmountBaseUnit, + slippage, + apiClient, + onSwapSuccess, + onSwapError, + refetchSellBalance, + refetchBuyBalance, + ]) + + const handleButtonClick = useCallback(() => { + if (!walletClient && canExecuteDirectly && onConnectWallet) { + onConnectWallet() + return + } + handleExecuteSwap() + }, [walletClient, canExecuteDirectly, onConnectWallet, handleExecuteSwap]) + + const buttonText = useMemo(() => { + if (!isSellAssetEvm) return 'Proceed on ShapeShift' + if (!sellAmount) return 'Enter an amount' + if (!walletClient && canExecuteDirectly) return 'Connect Wallet' + if (isLoadingRates) return 'Finding rates...' + if (ratesError) return 'No routes available' + if (!rates?.length) return 'No routes found' + if (isExecuting) return 'Executing...' + if (!canExecuteDirectly) return 'Proceed on ShapeShift' + return 'Swap' + }, [ + walletClient, + canExecuteDirectly, + isSellAssetEvm, + sellAmount, + isLoadingRates, + ratesError, + rates, + isExecuting, + ]) + + const isButtonDisabled = useMemo(() => { + if (!isSellAssetEvm) return false // Allow non-EVM without amount + if (!sellAmount) return true + if (isLoadingRates) return true + if (ratesError) return true + if (!rates?.length) return true + if (isExecuting) return true + return false + }, [isSellAssetEvm, sellAmount, isLoadingRates, ratesError, rates, isExecuting]) + + const { data: sellChainInfo } = useChainInfo(sellAsset.chainId) + const { data: buyChainInfo } = useChainInfo(buyAsset.chainId) + const displayRate = selectedRate ?? rates?.[0] + const buyAmount = displayRate?.buyAmountCryptoBaseUnit + + const assetIdsForPrices = useMemo( + () => [sellAsset.assetId, buyAsset.assetId], + [sellAsset.assetId, buyAsset.assetId], + ) + const { data: marketData } = useMarketData(assetIdsForPrices) + const sellAssetUsdPrice = marketData?.[sellAsset.assetId]?.price + const buyAssetUsdPrice = marketData?.[buyAsset.assetId]?.price + + const sellUsdValue = useMemo(() => { + if (!sellAmountBaseUnit || !sellAssetUsdPrice) return '$0.00' + return formatUsdValue(sellAmountBaseUnit, sellAsset.precision, sellAssetUsdPrice) + }, [sellAmountBaseUnit, sellAsset.precision, sellAssetUsdPrice]) + + const buyUsdValue = useMemo(() => { + if (!buyAmount || !buyAssetUsdPrice) return '$0.00' + return formatUsdValue(buyAmount, buyAsset.precision, buyAssetUsdPrice) + }, [buyAmount, buyAsset.precision, buyAssetUsdPrice]) + + const widgetStyle = useMemo(() => { + if (!themeConfig) return undefined + const style: Record = {} + if (themeConfig.accentColor) { + style['--ssw-accent'] = themeConfig.accentColor + style['--ssw-accent-light'] = `${themeConfig.accentColor}1a` + } + if (themeConfig.backgroundColor) { + style['--ssw-bg-secondary'] = themeConfig.backgroundColor + style['--ssw-bg-primary'] = themeConfig.backgroundColor + } + if (themeConfig.cardColor) { + style['--ssw-bg-tertiary'] = themeConfig.cardColor + style['--ssw-bg-input'] = themeConfig.cardColor + } + if (themeConfig.textColor) { + style['--ssw-text-primary'] = themeConfig.textColor + } + if (themeConfig.borderRadius) { + style['--ssw-border-radius'] = themeConfig.borderRadius + } + return Object.keys(style).length > 0 ? (style as React.CSSProperties) : undefined + }, [themeConfig]) + + return ( +
+
+ Swap +
+ {enableWalletConnection && } + +
+
+ +
+
+
+ Sell + {walletAddress && isSellAssetEvm && ( + {truncateAddress(walletAddress)} + )} +
+ +
+ { + setSellAmount(e.target.value.replace(/[^0-9.]/g, '')) + setSelectedRate(null) + }} + /> + +
+ +
+ {sellUsdValue} + {walletAddress && + (isSellBalanceLoading ? ( + + ) : sellAssetBalance ? ( + Balance: {sellAssetBalance.balanceFormatted} + ) : null)} +
+
+ +
+ +
+ +
+
+ Buy + {defaultReceiveAddress ? ( + + + {truncateAddress(defaultReceiveAddress, 4)} + + + ) : ( + + )} +
+ +
+ + +
+ +
+ {buyUsdValue} + {walletAddress && + (isBuyBalanceLoading ? ( + + ) : buyAssetBalance ? ( + Balance: {buyAssetBalance.balanceFormatted} + ) : null)} +
+
+
+ + {sellAmountBaseUnit && sellAmountBaseUnit !== '0' && (rates?.length || isLoadingRates) && ( +
+ +
+ )} + + + + {txStatus && ( +
+
+ {txStatus.status === 'pending' && ( + + + + + )} + {txStatus.status === 'success' && ( + + + + )} + {txStatus.status === 'error' && ( + + + + + )} +
+
+ {txStatus.message} + {txStatus.txHash && ( + + View transaction + + )} +
+ +
+ )} + + {showPoweredBy && ( +
+ Powered by{' '} + + + + + ShapeShift + +
+ )} + + setTokenModalType(null)} + onSelect={tokenModalType === 'sell' ? handleSellAssetSelect : handleBuyAssetSelect} + disabledAssetIds={disabledAssetIds} + disabledChainIds={disabledChainIds} + allowedChainIds={allowedChainIds} + walletAddress={walletAddress} + /> + + setIsSettingsOpen(false)} + slippage={slippage} + onSlippageChange={setSlippage} + /> + + setIsAddressModalOpen(false)} + chainId={buyAsset.chainId} + chainName={buyChainInfo?.name ?? buyAsset.networkName ?? buyAsset.name} + currentAddress={customReceiveAddress || walletAddress || ''} + onAddressChange={setCustomReceiveAddress} + walletAddress={walletAddress} + /> +
+ ) +} + +const SwapWidgetWithExternalWallet = (props: SwapWidgetProps) => { + const apiClient = useMemo( + () => createApiClient({ baseUrl: props.apiBaseUrl, apiKey: props.apiKey }), + [props.apiBaseUrl, props.apiKey], + ) + + return ( + + + + ) +} + +const SwapWidgetWithInternalWallet = ( + props: SwapWidgetProps & { walletConnectProjectId: string }, +) => { + const apiClient = useMemo( + () => createApiClient({ baseUrl: props.apiBaseUrl, apiKey: props.apiKey }), + [props.apiBaseUrl, props.apiKey], + ) + + const themeMode = typeof props.theme === 'string' ? props.theme : props.theme?.mode ?? 'dark' + + return ( + + {walletClient => ( + + + + )} + + ) +} + +export const SwapWidget = (props: SwapWidgetProps) => { + if (props.enableWalletConnection && props.walletConnectProjectId) { + return ( + + ) + } + + return +} diff --git a/packages/swap-widget/src/components/TokenSelectModal.css b/packages/swap-widget/src/components/TokenSelectModal.css new file mode 100644 index 00000000000..63fddab199d --- /dev/null +++ b/packages/swap-widget/src/components/TokenSelectModal.css @@ -0,0 +1,404 @@ +.ssw-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 16px; +} + +.ssw-modal { + background: var(--ssw-bg-secondary); + border-radius: 16px; + width: 100%; + max-width: 680px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); +} + +.ssw-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--ssw-border); +} + +.ssw-modal-title { + font-size: 18px; + font-weight: 600; + color: var(--ssw-text-primary); + margin: 0; +} + +.ssw-modal-close { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--ssw-text-secondary); + border-radius: 8px; + transition: all 0.15s ease; +} + +.ssw-modal-close:hover { + background: var(--ssw-bg-hover); + color: var(--ssw-text-primary); +} + +.ssw-modal-content { + display: flex; + flex: 1; + min-height: 0; +} + +.ssw-chain-sidebar { + width: 200px; + border-right: 1px solid var(--ssw-border); + display: flex; + flex-direction: column; + background: var(--ssw-bg-tertiary); +} + +.ssw-search-wrapper { + position: relative; + padding: 16px; +} + +.ssw-search-icon { + position: absolute; + left: 28px; + top: 50%; + transform: translateY(-50%); + color: var(--ssw-text-muted); + pointer-events: none; +} + +.ssw-chain-search, +.ssw-token-search { + width: 100%; + padding: 10px 12px 10px 36px; + border: 1px solid var(--ssw-border); + border-radius: 10px; + background: var(--ssw-bg-input); + color: var(--ssw-text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; +} + +.ssw-chain-search:focus, +.ssw-token-search:focus { + border-color: var(--ssw-accent); +} + +.ssw-chain-search::placeholder, +.ssw-token-search::placeholder { + color: var(--ssw-text-muted); +} + +.ssw-chain-list { + flex: 1; + overflow-y: auto; + padding: 0 8px 16px; + scrollbar-width: thin; + scrollbar-color: var(--ssw-border) transparent; + min-height: 300px; +} + +.ssw-chain-list::-webkit-scrollbar { + width: 6px; +} + +.ssw-chain-list::-webkit-scrollbar-track { + background: transparent; +} + +.ssw-chain-list::-webkit-scrollbar-thumb { + background: var(--ssw-border); + border-radius: 3px; +} + +.ssw-chain-list::-webkit-scrollbar-thumb:hover { + background: var(--ssw-border-hover); +} + +.ssw-chain-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: none; + background: none; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s ease; + text-align: left; +} + +.ssw-chain-item:hover { + background: var(--ssw-bg-hover); +} + +.ssw-chain-item.ssw-selected { + background: var(--ssw-accent-light); +} + +.ssw-chain-icon, +.ssw-chain-icon-placeholder, +.ssw-chain-icon-multi { + width: 28px; + height: 28px; + border-radius: 8px; + flex-shrink: 0; +} + +.ssw-chain-icon { + object-fit: contain; +} + +.ssw-chain-icon-placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: white; +} + +.ssw-chain-icon-multi { + display: flex; + align-items: center; + justify-content: center; + background: var(--ssw-bg-hover); + font-size: 14px; +} + +.ssw-chain-name { + font-size: 14px; + font-weight: 500; + color: var(--ssw-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ssw-token-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.ssw-token-list { + flex: 1; + overflow-y: auto; + padding: 0 16px 16px; + scrollbar-width: thin; + scrollbar-color: var(--ssw-border) transparent; +} + +.ssw-token-list::-webkit-scrollbar { + width: 6px; +} + +.ssw-token-list::-webkit-scrollbar-track { + background: transparent; +} + +.ssw-token-list::-webkit-scrollbar-thumb { + background: var(--ssw-border); + border-radius: 3px; +} + +.ssw-token-list::-webkit-scrollbar-thumb:hover { + background: var(--ssw-border-hover); +} + +.ssw-token-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px; + border: none; + background: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.15s ease; + text-align: left; +} + +.ssw-token-item:hover { + background: var(--ssw-bg-hover); +} + +.ssw-token-icon-wrapper { + position: relative; + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.ssw-token-icon, +.ssw-token-icon-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.ssw-token-icon { + object-fit: contain; +} + +.ssw-token-icon-placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + color: white; +} + +.ssw-token-chain-badge { + position: absolute; + bottom: -2px; + right: -2px; + width: 16px; + height: 16px; + border-radius: 4px; + border: 2px solid var(--ssw-bg-secondary); + background: var(--ssw-bg-secondary); +} + +.ssw-token-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ssw-token-symbol { + font-size: 15px; + font-weight: 600; + color: var(--ssw-text-primary); +} + +.ssw-token-name { + font-size: 13px; + color: var(--ssw-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ssw-token-right { + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.ssw-token-fiat-value { + font-size: 14px; + font-weight: 500; + color: var(--ssw-text-primary); +} + +.ssw-token-balance { + font-size: 13px; + font-weight: 400; + color: var(--ssw-text-secondary); +} + +.ssw-token-balance-skeleton { + width: 60px; + height: 14px; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--ssw-bg-hover) 25%, + var(--ssw-border) 50%, + var(--ssw-bg-hover) 75% + ); + background-size: 200% 100%; + animation: ssw-skeleton-shimmer 1.5s infinite; + flex-shrink: 0; +} + +@keyframes ssw-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.ssw-loading, +.ssw-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 24px; + color: var(--ssw-text-secondary); + font-size: 14px; +} + +.ssw-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--ssw-border); + border-top-color: var(--ssw-accent); + border-radius: 50%; + animation: ssw-spin 0.8s linear infinite; +} + +@keyframes ssw-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 600px) { + .ssw-modal { + max-height: 90vh; + } + + .ssw-modal-content { + flex-direction: column; + } + + .ssw-chain-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--ssw-border); + max-height: 200px; + } + + .ssw-chain-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 16px 16px; + } + + .ssw-chain-item { + padding: 8px 12px; + } + + .ssw-chain-name { + font-size: 13px; + } +} diff --git a/packages/swap-widget/src/components/TokenSelectModal.tsx b/packages/swap-widget/src/components/TokenSelectModal.tsx new file mode 100644 index 00000000000..9b0a5a95ed6 --- /dev/null +++ b/packages/swap-widget/src/components/TokenSelectModal.tsx @@ -0,0 +1,384 @@ +import './TokenSelectModal.css' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Virtuoso } from 'react-virtuoso' + +import type { ChainInfo } from '../hooks/useAssets' +import { useAssets, useChains } from '../hooks/useAssets' +import { useEvmBalances } from '../hooks/useBalances' +import { useAllMarketData } from '../hooks/useMarketData' +import type { Asset, AssetId, ChainId } from '../types' + +const VISIBLE_BUFFER = 10 + +const useLockBodyScroll = (isLocked: boolean) => { + useEffect(() => { + if (!isLocked) return + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, [isLocked]) +} + +type TokenSelectModalProps = { + isOpen: boolean + onClose: () => void + onSelect: (asset: Asset) => void + disabledAssetIds?: string[] + disabledChainIds?: ChainId[] + allowedChainIds?: ChainId[] + walletAddress?: string +} + +const isNativeAsset = (assetId: string): boolean => { + return assetId.includes('/slip44:') || assetId.endsWith('/native') +} + +const scoreAsset = (asset: Asset, query: string): number => { + const lowerQuery = query.toLowerCase() + const symbolLower = asset.symbol.toLowerCase() + const nameLower = asset.name.toLowerCase() + + let score = 0 + + if (symbolLower === lowerQuery) score += 1000 + else if (symbolLower.startsWith(lowerQuery)) score += 500 + else if (symbolLower.includes(lowerQuery)) score += 100 + + if (nameLower === lowerQuery) score += 800 + else if (nameLower.startsWith(lowerQuery)) score += 300 + else if (nameLower.includes(lowerQuery)) score += 50 + + if (isNativeAsset(asset.assetId)) score += 200 + + return score +} + +export const TokenSelectModal = ({ + isOpen, + onClose, + onSelect, + disabledAssetIds = [], + disabledChainIds = [], + allowedChainIds, + walletAddress, +}: TokenSelectModalProps) => { + useLockBodyScroll(isOpen) + const [searchQuery, setSearchQuery] = useState('') + const [chainSearchQuery, setChainSearchQuery] = useState('') + const [selectedChainId, setSelectedChainId] = useState(null) + const [visibleRange, setVisibleRange] = useState({ + startIndex: 0, + endIndex: 20, + }) + + const { data: allAssets, isLoading: isLoadingAssets } = useAssets() + const { data: chains, isLoading: isLoadingChains } = useChains() + + const chainInfoMap = useMemo(() => { + const map = new Map() + for (const chain of chains) { + map.set(chain.chainId, chain) + } + return map + }, [chains]) + + const filteredChains = useMemo(() => { + let enabledChains = chains.filter(chain => !disabledChainIds.includes(chain.chainId)) + + if (allowedChainIds && allowedChainIds.length > 0) { + enabledChains = enabledChains.filter(chain => allowedChainIds.includes(chain.chainId)) + } + + if (!chainSearchQuery.trim()) return enabledChains + + const lowerQuery = chainSearchQuery.toLowerCase() + return enabledChains.filter(chain => chain.name.toLowerCase().includes(lowerQuery)) + }, [chains, chainSearchQuery, disabledChainIds, allowedChainIds]) + + const filteredAssets = useMemo(() => { + let assets = allAssets.filter( + asset => + !disabledAssetIds.includes(asset.assetId) && !disabledChainIds.includes(asset.chainId), + ) + + if (allowedChainIds && allowedChainIds.length > 0) { + assets = assets.filter(asset => allowedChainIds.includes(asset.chainId)) + } + + if (selectedChainId) { + assets = assets.filter(asset => asset.chainId === selectedChainId) + } + + if (!searchQuery.trim()) { + const natives = assets.filter(a => isNativeAsset(a.assetId)) + const others = assets + .filter(a => !isNativeAsset(a.assetId)) + .slice(0, Math.max(50 - natives.length, 30)) + return [...natives, ...others] + } + + const lowerQuery = searchQuery.toLowerCase() + return assets + .filter( + asset => + asset.symbol.toLowerCase().includes(lowerQuery) || + asset.name.toLowerCase().includes(lowerQuery) || + asset.assetId.toLowerCase().includes(lowerQuery), + ) + .map(asset => ({ asset, score: scoreAsset(asset, searchQuery) })) + .sort((a, b) => b.score - a.score) + .slice(0, 100) + .map(item => item.asset) + }, [allAssets, selectedChainId, searchQuery, disabledAssetIds, disabledChainIds, allowedChainIds]) + + const visibleAssets = useMemo(() => { + const start = Math.max(0, visibleRange.startIndex - VISIBLE_BUFFER) + const end = Math.min(filteredAssets.length, visibleRange.endIndex + VISIBLE_BUFFER) + return filteredAssets.slice(start, end) + }, [filteredAssets, visibleRange]) + + const assetPrecisions = useMemo(() => { + const precisions: Record = {} + for (const asset of visibleAssets) { + precisions[asset.assetId] = asset.precision + } + return precisions + }, [visibleAssets]) + + const assetIds = useMemo(() => visibleAssets.map(a => a.assetId), [visibleAssets]) + + const { data: balances, loadingAssetIds } = useEvmBalances( + walletAddress, + assetIds, + assetPrecisions, + ) + + const { data: marketData } = useAllMarketData() + + const handleAssetSelect = useCallback( + (asset: Asset) => { + onSelect(asset) + onClose() + setSearchQuery('') + setSelectedChainId(null) + }, + [onSelect, onClose], + ) + + const handleChainSelect = useCallback((chainId: ChainId | null) => { + setSelectedChainId(chainId) + }, []) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + if (!isOpen) return null + + const isLoading = isLoadingAssets || isLoadingChains + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.key === 'Escape' && onClose()} + role='dialog' + aria-modal='true' + aria-labelledby='token-modal-title' + > +
+
+

+ Select Token +

+ +
+ +
+
+
+ + + + + setChainSearchQuery(e.target.value)} + /> +
+ +
+ + + {filteredChains.map(chain => ( + + ))} +
+
+ +
+
+ + + + + setSearchQuery(e.target.value)} + autoFocus + /> +
+ +
+ {isLoading ? ( +
+
+ Loading assets... +
+ ) : filteredAssets.length === 0 ? ( +
No tokens found
+ ) : ( + { + const chainInfo = chainInfoMap.get(asset.chainId) + const balance = balances?.[asset.assetId] + return ( + + ) + }} + /> + )} +
+
+
+
+
+ ) +} diff --git a/packages/swap-widget/src/components/WalletProvider.tsx b/packages/swap-widget/src/components/WalletProvider.tsx new file mode 100644 index 00000000000..845b22c719f --- /dev/null +++ b/packages/swap-widget/src/components/WalletProvider.tsx @@ -0,0 +1,112 @@ +import { ConnectButton, darkTheme, lightTheme, RainbowKitProvider } from '@rainbow-me/rainbowkit' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useMemo } from 'react' +import type { WalletClient } from 'viem' +import { useWalletClient, WagmiProvider } from 'wagmi' + +import type { WagmiConfig } from '../config/wagmi' +import { createWagmiConfig } from '../config/wagmi' +import type { ThemeMode } from '../types' + +const queryClient = new QueryClient() + +type InternalWalletProviderProps = { + projectId: string + children: (walletClient: WalletClient | undefined) => ReactNode + themeMode: ThemeMode +} + +const InternalWalletContent = ({ + children, +}: { + children: (walletClient: WalletClient | undefined) => ReactNode +}) => { + const { data: walletClient } = useWalletClient() + return <>{children(walletClient)} +} + +export const InternalWalletProvider = ({ + projectId, + children, + themeMode, +}: InternalWalletProviderProps) => { + const config: WagmiConfig = useMemo(() => createWagmiConfig(projectId), [projectId]) + + return ( + + + + {children} + + + + ) +} + +export const ConnectWalletButton = () => { + return ( + + {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => { + const ready = mounted + const connected = ready && account && chain + + return ( +
+ {(() => { + if (!connected) { + return ( + + ) + } + + if (chain.unsupported) { + return ( + + ) + } + + return ( + + ) + })()} +
+ ) + }} +
+ ) +} diff --git a/packages/swap-widget/src/config/wagmi.ts b/packages/swap-widget/src/config/wagmi.ts new file mode 100644 index 00000000000..0515acb847c --- /dev/null +++ b/packages/swap-widget/src/config/wagmi.ts @@ -0,0 +1,49 @@ +import { getDefaultConfig } from '@rainbow-me/rainbowkit' +import type { Config } from 'wagmi' +import { + arbitrum, + arbitrumNova, + avalanche, + base, + bsc, + gnosis, + hyperEvm, + katana, + mainnet, + monad, + optimism, + plasma, + polygon, +} from 'wagmi/chains' + +export const SUPPORTED_CHAINS = [ + mainnet, + polygon, + arbitrum, + arbitrumNova, + optimism, + base, + avalanche, + bsc, + gnosis, + monad, + hyperEvm, + plasma, + katana, +] as const + +export type SupportedChains = typeof SUPPORTED_CHAINS +export type SupportedChainId = SupportedChains[number]['id'] + +export type WagmiConfig = Config + +export const createWagmiConfig = ( + projectId: string, + appName: string = 'ShapeShift Swap Widget', +): WagmiConfig => + getDefaultConfig({ + appName, + projectId, + chains: SUPPORTED_CHAINS, + ssr: false, + }) as unknown as WagmiConfig diff --git a/packages/swap-widget/src/constants/chains.ts b/packages/swap-widget/src/constants/chains.ts new file mode 100644 index 00000000000..b08eee790d9 --- /dev/null +++ b/packages/swap-widget/src/constants/chains.ts @@ -0,0 +1,72 @@ +import type { Asset } from '@shapeshiftoss/types' +import { + arbitrum, + arbitrumNova, + atom, + avax, + base, + bitcoin, + bitcoincash, + bnbsmartchain, + dogecoin, + ethereum, + gnosis, + hyperevm, + katana, + litecoin, + mayachain, + monad, + optimism, + plasma, + polygon, + solana, + thorchain, +} from '@shapeshiftoss/utils' + +import type { ChainId } from '../types' + +const BASE_ASSETS_BY_CHAIN_ID: Record = { + [ethereum.chainId]: ethereum, + [arbitrum.chainId]: arbitrum, + [arbitrumNova.chainId]: arbitrumNova, + [optimism.chainId]: optimism, + [polygon.chainId]: polygon, + [base.chainId]: base, + [avax.chainId]: avax, + [bnbsmartchain.chainId]: bnbsmartchain, + [gnosis.chainId]: gnosis, + [monad.chainId]: monad, + [hyperevm.chainId]: hyperevm, + [plasma.chainId]: plasma, + [katana.chainId]: katana, + [bitcoin.chainId]: bitcoin, + [bitcoincash.chainId]: bitcoincash, + [dogecoin.chainId]: dogecoin, + [litecoin.chainId]: litecoin, + [atom.chainId]: atom, + [thorchain.chainId]: thorchain, + [mayachain.chainId]: mayachain, + [solana.chainId]: solana, +} + +export const getBaseAsset = (chainId: ChainId): Asset | undefined => { + return BASE_ASSETS_BY_CHAIN_ID[chainId] +} + +export const getChainName = (chainId: ChainId): string => { + return BASE_ASSETS_BY_CHAIN_ID[chainId]?.networkName ?? chainId +} + +export const getChainIcon = (chainId: ChainId): string | undefined => { + const asset = BASE_ASSETS_BY_CHAIN_ID[chainId] + return asset?.networkIcon ?? asset?.icon +} + +export const getChainColor = (chainId: ChainId): string => { + const asset = BASE_ASSETS_BY_CHAIN_ID[chainId] + return (asset as Asset & { networkColor?: string })?.networkColor ?? asset?.color ?? '#888888' +} + +export const getExplorerTxLink = (chainId: ChainId): string => { + return BASE_ASSETS_BY_CHAIN_ID[chainId]?.explorerTxLink ?? 'https://etherscan.io/tx/' +} diff --git a/packages/swap-widget/src/constants/swappers.ts b/packages/swap-widget/src/constants/swappers.ts new file mode 100644 index 00000000000..d61a0e5040a --- /dev/null +++ b/packages/swap-widget/src/constants/swappers.ts @@ -0,0 +1,48 @@ +import { SwapperName } from '../types' + +export const SWAPPER_ICONS: Partial> = { + [SwapperName.Thorchain]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/thorchain-icon.png', + [SwapperName.Mayachain]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/maya_logo.png', + [SwapperName.CowSwap]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/cow-icon.png', + [SwapperName.Zrx]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/0x-icon.png', + [SwapperName.Portals]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/portals-icon.png', + [SwapperName.Chainflip]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/chainflip-icon.png', + [SwapperName.Relay]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/relay-icon.svg', + [SwapperName.Bebop]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/bebop-icon.png', + [SwapperName.Jupiter]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/jupiter-icon.svg', + [SwapperName.ButterSwap]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/butterswap.png', + [SwapperName.ArbitrumBridge]: + 'https://raw.githubusercontent.com/shapeshift/web/develop/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/arbitrum-bridge-icon.png', +} + +export const SWAPPER_COLORS: Partial> = { + [SwapperName.Thorchain]: '#00CCFF', + [SwapperName.Mayachain]: '#4169E1', + [SwapperName.CowSwap]: '#012d73', + [SwapperName.Zrx]: '#000000', + [SwapperName.Portals]: '#8B5CF6', + [SwapperName.Chainflip]: '#FF4081', + [SwapperName.Relay]: '#6366F1', + [SwapperName.Bebop]: '#E91E63', + [SwapperName.Jupiter]: '#C4A962', + [SwapperName.ButterSwap]: '#FFD700', + [SwapperName.ArbitrumBridge]: '#28A0F0', +} + +export const getSwapperIcon = (swapperName: SwapperName): string | undefined => { + return SWAPPER_ICONS[swapperName] +} + +export const getSwapperColor = (swapperName: SwapperName): string => { + return SWAPPER_COLORS[swapperName] ?? '#6366F1' +} diff --git a/packages/swap-widget/src/demo/App.css b/packages/swap-widget/src/demo/App.css new file mode 100644 index 00000000000..8ac66e33e0c --- /dev/null +++ b/packages/swap-widget/src/demo/App.css @@ -0,0 +1,451 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + min-height: 100vh; +} + +.demo-app { + width: 100%; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + display: flex; + flex-direction: column; +} + +.demo-app.dark { + --demo-bg: var(--demo-page-bg, #09090f); + --demo-bg-secondary: color-mix( + in srgb, + var(--demo-page-bg, #09090f), + white 5% + ); + --demo-text: #ffffff; + --demo-text-secondary: #a0a0b0; + --demo-text-muted: #6b6b80; + --demo-border: rgba(255, 255, 255, 0.08); + --demo-accent: var(--demo-page-accent, #3861fb); + + background: var(--demo-bg); + background-image: radial-gradient( + ellipse 80% 50% at 50% -20%, + color-mix(in srgb, var(--demo-page-accent, #3861fb), transparent 88%), + transparent + ), + radial-gradient( + ellipse 60% 40% at 80% 100%, + color-mix(in srgb, var(--demo-page-accent, #6366f1), transparent 92%), + transparent + ); + color: var(--demo-text); +} + +.demo-app.light { + --demo-bg: #f5f5f7; + --demo-bg-secondary: #ffffff; + --demo-text: #1a1a2e; + --demo-text-secondary: #5c5c70; + --demo-text-muted: #9090a0; + --demo-border: rgba(0, 0, 0, 0.08); + --demo-accent: #3861fb; + + background: var(--demo-bg); + color: var(--demo-text); +} + +.demo-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--demo-border); + background: var(--demo-bg-secondary); + position: sticky; + top: 0; + z-index: 100; +} + +.demo-logo { + display: flex; + align-items: center; + gap: 10px; + color: var(--demo-text); + text-decoration: none; + transition: opacity 0.15s ease; +} + +.demo-logo:hover { + opacity: 0.8; +} + +.demo-logo-text { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.demo-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.demo-customize-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border: 1px solid var(--demo-border); + border-radius: 12px; + background: transparent; + color: var(--demo-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.demo-customize-btn:hover { + border-color: var(--demo-accent); + color: var(--demo-text); +} + +.demo-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 24px; +} + +.demo-content { + width: 100%; + max-width: 1100px; + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; +} + +.demo-hero { + text-align: center; + max-width: 500px; +} + +.demo-title { + font-size: 42px; + font-weight: 700; + letter-spacing: -0.03em; + margin-bottom: 12px; + color: var(--demo-accent); +} + +.demo-subtitle { + font-size: 16px; + color: var(--demo-text-secondary); + line-height: 1.5; +} + +.demo-layout { + display: flex; + gap: 24px; + align-items: flex-start; + justify-content: center; + width: 100%; +} + +.demo-customizer { + width: 280px; + padding: 20px; + background: var(--demo-bg-secondary); + border: 1px solid var(--demo-border); + border-radius: 16px; + flex-shrink: 0; +} + +.demo-customizer-title { + font-size: 15px; + font-weight: 600; + color: var(--demo-text); + margin-bottom: 20px; +} + +.demo-customizer-section { + margin-bottom: 20px; +} + +.demo-customizer-section:last-child { + margin-bottom: 0; +} + +.demo-customizer-label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--demo-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 10px; +} + +.demo-theme-toggle { + display: flex; + gap: 8px; +} + +.demo-theme-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px; + border: 1px solid var(--demo-border); + border-radius: 10px; + background: transparent; + color: var(--demo-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.demo-theme-btn:hover { + border-color: var(--demo-text-muted); +} + +.demo-theme-btn.active { + border-color: var(--demo-accent); + background: color-mix(in srgb, var(--demo-accent), transparent 90%); + color: var(--demo-accent); +} + +.demo-color-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} + +.demo-color-btn { + width: 100%; + aspect-ratio: 1; + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.demo-color-btn:hover { + transform: scale(1.1); +} + +.demo-color-btn.active { + border-color: var(--demo-text); + box-shadow: 0 0 0 2px var(--demo-bg); +} + +.demo-preset-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.demo-preset-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px; + border: 1px solid var(--demo-border); + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s ease; +} + +.demo-preset-btn:hover { + border-color: var(--demo-accent); + transform: translateY(-1px); +} + +.demo-preset-preview { + width: 100%; + aspect-ratio: 1.2; + border-radius: 6px; + position: relative; + overflow: hidden; +} + +.demo-preset-card { + position: absolute; + inset: 20%; + border-radius: 4px; +} + +.demo-preset-accent { + position: absolute; + bottom: 15%; + left: 25%; + right: 25%; + height: 12%; + border-radius: 2px; +} + +.demo-preset-name { + font-size: 10px; + font-weight: 500; + color: var(--demo-text-secondary); +} + +.demo-color-input-row { + display: flex; + gap: 8px; + align-items: center; +} + +.demo-color-picker { + width: 40px; + height: 40px; + padding: 0; + border: 1px solid var(--demo-border); + border-radius: 8px; + cursor: pointer; + background: transparent; +} + +.demo-color-picker::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.demo-color-picker::-webkit-color-swatch { + border-radius: 6px; + border: none; +} + +.demo-color-text { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--demo-border); + border-radius: 8px; + background: var(--demo-bg); + color: var(--demo-text); + font-size: 13px; + font-family: monospace; +} + +.demo-color-text:focus { + outline: none; + border-color: var(--demo-accent); +} + +.demo-copy-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 12px 16px; + border: 1px solid var(--demo-accent); + border-radius: 10px; + background: color-mix(in srgb, var(--demo-accent), transparent 90%); + color: var(--demo-accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.demo-copy-btn:hover { + background: color-mix(in srgb, var(--demo-accent), transparent 80%); +} + +.demo-copy-btn:active { + transform: scale(0.98); +} + +.demo-connection-info { + display: flex; + flex-direction: column; + gap: 6px; +} + +.demo-connected-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + background: rgba(16, 185, 129, 0.1); + color: #10b981; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + width: fit-content; +} + +.demo-address { + font-size: 13px; + color: var(--demo-text-secondary); + font-family: monospace; +} + +.demo-disconnected { + font-size: 13px; + color: var(--demo-text-muted); +} + +.demo-widget-container { + display: flex; + justify-content: center; +} + +.demo-footer { + padding: 20px; + text-align: center; + border-top: 1px solid var(--demo-border); + color: var(--demo-text-muted); + font-size: 13px; +} + +@media (max-width: 768px) { + .demo-header { + padding: 12px 16px; + flex-wrap: wrap; + gap: 12px; + } + + .demo-customize-btn span { + display: none; + } + + .demo-customize-btn { + padding: 10px; + } + + .demo-title { + font-size: 32px; + } + + .demo-subtitle { + font-size: 14px; + } + + .demo-layout { + flex-direction: column; + align-items: center; + } + + .demo-customizer { + width: 100%; + max-width: 420px; + } + + .demo-main { + padding: 32px 16px; + } +} diff --git a/packages/swap-widget/src/demo/App.tsx b/packages/swap-widget/src/demo/App.tsx new file mode 100644 index 00000000000..f984e91aa2e --- /dev/null +++ b/packages/swap-widget/src/demo/App.tsx @@ -0,0 +1,437 @@ +import '@rainbow-me/rainbowkit/styles.css' +import './App.css' + +import { ConnectButton, darkTheme, lightTheme, RainbowKitProvider } from '@rainbow-me/rainbowkit' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' +import { useAccount, useWalletClient, WagmiProvider } from 'wagmi' + +import { SwapWidget } from '../components/SwapWidget' +import type { WagmiConfig } from '../config/wagmi' +import { createWagmiConfig } from '../config/wagmi' +import type { ThemeConfig } from '../types' + +const config: WagmiConfig = createWagmiConfig('f58c0242def84c3b9befe9b1e6086bbd') + +const queryClient = new QueryClient() + +type ThemeColors = { + bg: string + card: string + accent: string +} + +const THEME_PRESETS: { + name: string + dark: ThemeColors + light: ThemeColors +}[] = [ + { + name: 'Blue', + dark: { bg: '#0a0a14', card: '#12121c', accent: '#3861fb' }, + light: { bg: '#f8f9fc', card: '#ffffff', accent: '#3861fb' }, + }, + { + name: 'Rose', + dark: { bg: '#140a0f', card: '#1c1218', accent: '#f43f5e' }, + light: { bg: '#fef2f4', card: '#ffffff', accent: '#f43f5e' }, + }, + { + name: 'Purple', + dark: { bg: '#0e0a14', card: '#1a1424', accent: '#a855f7' }, + light: { bg: '#faf5ff', card: '#ffffff', accent: '#a855f7' }, + }, + { + name: 'Cyan', + dark: { bg: '#0a1214', card: '#141d20', accent: '#06b6d4' }, + light: { bg: '#f0fdff', card: '#ffffff', accent: '#06b6d4' }, + }, + { + name: 'Green', + dark: { bg: '#0a140e', card: '#141c18', accent: '#10b981' }, + light: { bg: '#f0fdf6', card: '#ffffff', accent: '#10b981' }, + }, + { + name: 'Orange', + dark: { bg: '#14100a', card: '#1c1814', accent: '#f97316' }, + light: { bg: '#fff8f3', card: '#ffffff', accent: '#f97316' }, + }, +] + +type DemoContentProps = { + theme: 'light' | 'dark' + setTheme: (theme: 'light' | 'dark') => void +} + +const DemoContent = ({ theme, setTheme }: DemoContentProps) => { + const { address, isConnected } = useAccount() + const { data: walletClient } = useWalletClient() + const [showCustomizer, setShowCustomizer] = useState(true) + + const [darkColors, setDarkColors] = useState({ + bg: '#0a0a14', + card: '#12121c', + accent: '#3861fb', + }) + + const [lightColors, setLightColors] = useState({ + bg: '#f8f9fc', + card: '#ffffff', + accent: '#3861fb', + }) + + const currentColors = theme === 'dark' ? darkColors : lightColors + const setCurrentColors = theme === 'dark' ? setDarkColors : setLightColors + + const themeConfig: ThemeConfig = useMemo( + () => ({ + mode: theme, + accentColor: currentColors.accent, + backgroundColor: currentColors.bg, + cardColor: currentColors.card, + }), + [theme, currentColors], + ) + + const applyPreset = (preset: (typeof THEME_PRESETS)[0]) => { + setDarkColors(preset.dark) + setLightColors(preset.light) + } + + const [copied, setCopied] = useState(false) + + const copyConfig = useCallback(() => { + const code = `const themeConfig = { + dark: { + mode: "dark", + backgroundColor: "${darkColors.bg}", + cardColor: "${darkColors.card}", + accentColor: "${darkColors.accent}", + }, + light: { + mode: "light", + backgroundColor: "${lightColors.bg}", + cardColor: "${lightColors.card}", + accentColor: "${lightColors.accent}", + }, +}; + +// Usage: + +// or +` + + navigator.clipboard.writeText(code).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, [darkColors, lightColors]) + + const handleSwapSuccess = (txHash: string) => { + console.log('Swap successful:', txHash) + } + + const handleSwapError = (error: Error) => { + console.error('Swap failed:', error) + } + + const demoStyle = useMemo( + () => + ({ + '--demo-page-bg': currentColors.bg, + '--demo-page-accent': currentColors.accent, + }) as React.CSSProperties, + [currentColors], + ) + + return ( +
+
+ + + + + ShapeShift + + +
+ + +
+
+ +
+
+
+

Swap Widget

+

+ Embeddable multi-chain swap widget powered by ShapeShift +

+
+ +
+ {showCustomizer && ( +
+

Customize Widget

+ +
+ Presets +
+ {THEME_PRESETS.map(preset => { + const previewColors = theme === 'dark' ? preset.dark : preset.light + return ( + + ) + })} +
+
+ +
+ Theme +
+ + +
+
+ +
+ Background Color +
+ setCurrentColors(c => ({ ...c, bg: e.target.value }))} + className='demo-color-picker' + /> + setCurrentColors(c => ({ ...c, bg: e.target.value }))} + className='demo-color-text' + /> +
+
+ +
+ Card Color +
+ + setCurrentColors(c => ({ + ...c, + card: e.target.value, + })) + } + className='demo-color-picker' + /> + + setCurrentColors(c => ({ + ...c, + card: e.target.value, + })) + } + className='demo-color-text' + /> +
+
+ +
+ Accent Color +
+ + setCurrentColors(c => ({ + ...c, + accent: e.target.value, + })) + } + className='demo-color-picker' + /> + + setCurrentColors(c => ({ + ...c, + accent: e.target.value, + })) + } + className='demo-color-text' + /> +
+
+ +
+ Connection +
+ {isConnected ? ( + <> + Connected + + {address?.slice(0, 6)}...{address?.slice(-4)} + + + ) : ( + Not connected + )} +
+
+ +
+ +
+
+ )} + +
+ +
+
+
+
+ +
+

Built by ShapeShift DAO

+
+
+ ) +} + +export const App = () => { + const [theme, setTheme] = useState<'light' | 'dark'>('dark') + + const rainbowTheme = useMemo(() => (theme === 'dark' ? darkTheme() : lightTheme()), [theme]) + + return ( + + + + + + + + ) +} diff --git a/packages/swap-widget/src/demo/main.tsx b/packages/swap-widget/src/demo/main.tsx new file mode 100644 index 00000000000..f9cd0e181b8 --- /dev/null +++ b/packages/swap-widget/src/demo/main.tsx @@ -0,0 +1,25 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import ReactDOM from 'react-dom/client' + +import { App } from './App' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, +}) + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Root element not found') + +ReactDOM.createRoot(rootElement).render( + + + + + , +) diff --git a/packages/swap-widget/src/hooks/useAssets.ts b/packages/swap-widget/src/hooks/useAssets.ts new file mode 100644 index 00000000000..a1873be5173 --- /dev/null +++ b/packages/swap-widget/src/hooks/useAssets.ts @@ -0,0 +1,180 @@ +import { ASSET_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' + +import type { Asset, AssetId, ChainId } from '../types' + +const SHAPESHIFT_ASSET_CDN = 'https://app.shapeshift.com' +const ASSET_QUERY_STALE_TIME = 5 * 60 * 1000 + +type AssetManifest = { + assetData: string + relatedAssetIndex: string +} + +type RawAssetData = { + byId: Record + ids: AssetId[] +} + +const fetchAssetManifest = async (): Promise => { + const response = await fetch(`${SHAPESHIFT_ASSET_CDN}/generated/asset-manifest.json`) + if (!response.ok) { + return { assetData: Date.now().toString(), relatedAssetIndex: '' } + } + return response.json() +} + +const fetchAssetData = async (): Promise => { + const manifest = await fetchAssetManifest() + const response = await fetch( + `${SHAPESHIFT_ASSET_CDN}/generated/generatedAssetData.json?v=${manifest.assetData}`, + ) + + if (!response.ok) { + throw new Error('Failed to fetch asset data') + } + + return response.json() +} + +export const useAssetData = () => { + return useQuery({ + queryKey: ['assetData'], + queryFn: fetchAssetData, + staleTime: ASSET_QUERY_STALE_TIME, + gcTime: 30 * 60 * 1000, + }) +} + +export const useAssets = () => { + const { data, ...rest } = useAssetData() + + const assets = data ? data.ids.map(id => data.byId[id]).filter(Boolean) : [] + + return { data: assets, ...rest } +} + +export const useAssetsById = () => { + const { data, ...rest } = useAssetData() + return { data: data?.byId ?? {}, ...rest } +} + +export const useAssetById = (assetId: AssetId | undefined) => { + const { data: assetsById, ...rest } = useAssetsById() + return { + data: assetId ? assetsById[assetId] : undefined, + ...rest, + } +} + +export type ChainInfo = { + chainId: ChainId + name: string + icon?: string + color?: string + nativeAsset: Asset +} + +const isNativeAsset = (assetId: string): boolean => { + try { + const { assetNamespace } = fromAssetId(assetId) + return assetNamespace === ASSET_NAMESPACE.slip44 + } catch { + return false + } +} + +export const useChains = () => { + const { data: assets, ...rest } = useAssets() + + const chains = (() => { + if (!assets.length) return [] + + const chainMap = new Map() + + for (const asset of assets) { + if (chainMap.has(asset.chainId)) continue + + if (isNativeAsset(asset.assetId)) { + chainMap.set(asset.chainId, { + chainId: asset.chainId, + name: asset.networkName ?? asset.name, + icon: (asset as Asset & { networkIcon?: string }).networkIcon ?? asset.icon, + color: (asset as Asset & { networkColor?: string }).networkColor ?? asset.color, + nativeAsset: asset, + }) + } + } + + return Array.from(chainMap.values()).sort((a, b) => a.name.localeCompare(b.name)) + })() + + return { data: chains, ...rest } +} + +export const useChainInfo = (chainId: ChainId | undefined) => { + const { data: chains, ...rest } = useChains() + const chainInfo = chainId ? chains.find(c => c.chainId === chainId) : undefined + return { data: chainInfo, ...rest } +} + +export const useAssetsByChainId = (chainId: ChainId | undefined) => { + const { data: assets, ...rest } = useAssets() + + const filteredAssets = chainId ? assets.filter(asset => asset.chainId === chainId) : assets + + return { data: filteredAssets, ...rest } +} + +const scoreAsset = (asset: Asset, query: string): number => { + const lowerQuery = query.toLowerCase() + const symbolLower = asset.symbol.toLowerCase() + const nameLower = asset.name.toLowerCase() + + let score = 0 + + if (symbolLower === lowerQuery) score += 1000 + else if (symbolLower.startsWith(lowerQuery)) score += 500 + else if (symbolLower.includes(lowerQuery)) score += 100 + + if (nameLower === lowerQuery) score += 800 + else if (nameLower.startsWith(lowerQuery)) score += 300 + else if (nameLower.includes(lowerQuery)) score += 50 + + if (isNativeAsset(asset.assetId)) score += 200 + + return score +} + +export const useAssetSearch = (query: string, chainId?: ChainId) => { + const { data: assets, ...rest } = useAssets() + + const searchResults = (() => { + let filtered = chainId ? assets.filter(a => a.chainId === chainId) : assets + + if (!query.trim()) { + const natives = filtered.filter(a => isNativeAsset(a.assetId)) + const others = filtered.filter(a => !isNativeAsset(a.assetId)).slice(0, 50 - natives.length) + return [...natives, ...others] + } + + const lowerQuery = query.toLowerCase() + + const matched = filtered + .filter(asset => { + return ( + asset.symbol.toLowerCase().includes(lowerQuery) || + asset.name.toLowerCase().includes(lowerQuery) || + asset.assetId.toLowerCase().includes(lowerQuery) + ) + }) + .map(asset => ({ asset, score: scoreAsset(asset, query) })) + .sort((a, b) => b.score - a.score) + .slice(0, 100) + .map(item => item.asset) + + return matched + })() + + return { data: searchResults, ...rest } +} diff --git a/packages/swap-widget/src/hooks/useBalances.ts b/packages/swap-widget/src/hooks/useBalances.ts new file mode 100644 index 00000000000..e61cfad5b37 --- /dev/null +++ b/packages/swap-widget/src/hooks/useBalances.ts @@ -0,0 +1,258 @@ +import { ASSET_NAMESPACE, CHAIN_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' +import { useQueries } from '@tanstack/react-query' +import { getBalance, readContract } from '@wagmi/core' +import PQueue from 'p-queue' +import { useMemo } from 'react' +import { erc20Abi } from 'viem' +import { useBalance, useConfig } from 'wagmi' + +import type { SupportedChainId } from '../config/wagmi' +import type { AssetId } from '../types' +import { formatAmount } from '../types' + +const CONCURRENCY_LIMIT = 5 +const DELAY_BETWEEN_BATCHES_MS = 50 + +const balanceQueue = new PQueue({ + concurrency: CONCURRENCY_LIMIT, + interval: DELAY_BETWEEN_BATCHES_MS, + intervalCap: CONCURRENCY_LIMIT, +}) + +type BalanceResult = { + assetId: AssetId + balance: string + balanceFormatted: string +} + +type BalancesMap = Record + +const parseAssetId = ( + assetId: AssetId, +): { chainId: number; tokenAddress?: `0x${string}` } | null => { + try { + const { chainNamespace, chainReference, assetNamespace, assetReference } = fromAssetId(assetId) + + if (chainNamespace !== CHAIN_NAMESPACE.Evm) return null + + const evmChainId = Number(chainReference) + + if (assetNamespace === ASSET_NAMESPACE.erc20) { + return { + chainId: evmChainId, + tokenAddress: assetReference as `0x${string}`, + } + } + + return { chainId: evmChainId } + } catch { + return null + } +} + +export const useAssetBalance = ( + address: string | undefined, + assetId: AssetId | undefined, + precision: number = 18, +) => { + const parsed = assetId ? parseAssetId(assetId) : null + const isNative = parsed && !parsed.tokenAddress + const isErc20 = parsed && !!parsed.tokenAddress + + const nativeChainId = isNative ? (parsed.chainId as SupportedChainId) : undefined + const erc20ChainId = isErc20 ? (parsed.chainId as SupportedChainId) : undefined + + const { + data: nativeBalance, + isLoading: isNativeLoading, + refetch: refetchNative, + } = useBalance({ + address: address as `0x${string}` | undefined, + // @ts-ignore - swap-widget supports more chains than main app's wagmi type registration + chainId: nativeChainId, + query: { + enabled: !!address && !!isNative, + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }) + + const { + data: erc20Balance, + isLoading: isErc20Loading, + refetch: refetchErc20, + } = useBalance({ + address: address as `0x${string}` | undefined, + // @ts-ignore - swap-widget supports more chains than main app's wagmi type registration + chainId: erc20ChainId, + token: isErc20 ? parsed.tokenAddress : undefined, + query: { + enabled: !!address && !!isErc20, + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }) + + const balance = isNative ? nativeBalance : isErc20 ? erc20Balance : undefined + const isLoading = isNative ? isNativeLoading : isErc20 ? isErc20Loading : false + const refetch = isNative ? refetchNative : isErc20 ? refetchErc20 : undefined + + return useMemo(() => { + if (!balance || !assetId) { + return { data: undefined, isLoading, refetch } + } + + return { + data: { + assetId, + balance: balance.value.toString(), + balanceFormatted: formatAmount(balance.value.toString(), precision), + }, + isLoading, + refetch, + } + }, [balance, assetId, precision, isLoading, refetch]) +} + +export const useEvmBalances = ( + address: string | undefined, + assetIds: AssetId[], + assetPrecisions: Record, +) => { + const config = useConfig() + + const parsedAssets = useMemo(() => { + return assetIds + .map(assetId => { + const parsed = parseAssetId(assetId) + if (!parsed) return null + return { + assetId, + chainId: parsed.chainId as SupportedChainId, + tokenAddress: parsed.tokenAddress, + precision: assetPrecisions[assetId] ?? 18, + isNative: !parsed.tokenAddress, + } + }) + .filter(Boolean) as { + assetId: AssetId + chainId: SupportedChainId + tokenAddress?: `0x${string}` + precision: number + isNative: boolean + }[] + }, [assetIds, assetPrecisions]) + + const nativeAssets = useMemo(() => parsedAssets.filter(a => a.isNative), [parsedAssets]) + + const erc20Assets = useMemo(() => parsedAssets.filter(a => !a.isNative), [parsedAssets]) + + const nativeQueries = useQueries({ + queries: nativeAssets.map(asset => ({ + queryKey: ['nativeBalance', address, asset.chainId], + queryFn: () => { + if (!address) return Promise.resolve(null) + return balanceQueue.add(async () => { + try { + const result = await getBalance(config, { + address: address as `0x${string}`, + // @ts-ignore - swap-widget supports more chains than main app's wagmi type registration + chainId: asset.chainId, + }) + return { + assetId: asset.assetId, + balance: result.value.toString(), + precision: asset.precision, + } + } catch { + return null + } + }) + }, + enabled: !!address, + staleTime: 60_000, + refetchOnWindowFocus: false, + })), + }) + + const erc20Queries = useQueries({ + queries: erc20Assets.map(asset => ({ + queryKey: ['erc20Balance', address, asset.chainId, asset.tokenAddress], + queryFn: () => { + if (!address) return Promise.resolve(null) + return balanceQueue.add(async () => { + try { + if (!asset.tokenAddress) return null + const result = await readContract(config, { + address: asset.tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [address as `0x${string}`], + // @ts-ignore - swap-widget supports more chains than main app's wagmi type registration + chainId: asset.chainId, + }) + return { + assetId: asset.assetId, + balance: (result as bigint).toString(), + precision: asset.precision, + } + } catch { + return null + } + }) + }, + enabled: !!address, + staleTime: 60_000, + refetchOnWindowFocus: false, + })), + }) + + const isErc20Loading = erc20Queries.some(q => q.isLoading) + + const balances = useMemo((): BalancesMap => { + const result: BalancesMap = {} + + nativeQueries.forEach(query => { + if (query.data) { + const { assetId, balance, precision } = query.data + result[assetId] = { + assetId, + balance, + balanceFormatted: formatAmount(balance, precision), + } + } + }) + + erc20Queries.forEach(query => { + if (query.data) { + const { assetId, balance, precision } = query.data + result[assetId] = { + assetId, + balance, + balanceFormatted: formatAmount(balance, precision), + } + } + }) + + return result + }, [nativeQueries, erc20Queries]) + + const isLoading = nativeQueries.some(q => q.isLoading) || isErc20Loading + + const loadingAssetIds = useMemo(() => { + const loading = new Set() + nativeQueries.forEach((query, index) => { + if (query.isLoading) { + loading.add(nativeAssets[index].assetId) + } + }) + erc20Queries.forEach((query, index) => { + if (query.isLoading) { + loading.add(erc20Assets[index].assetId) + } + }) + return loading + }, [nativeQueries, nativeAssets, erc20Queries, erc20Assets]) + + return { data: balances, isLoading, loadingAssetIds } +} diff --git a/packages/swap-widget/src/hooks/useMarketData.ts b/packages/swap-widget/src/hooks/useMarketData.ts new file mode 100644 index 00000000000..95c43e67eda --- /dev/null +++ b/packages/swap-widget/src/hooks/useMarketData.ts @@ -0,0 +1,169 @@ +import { adapters } from '@shapeshiftoss/caip' +import { bn, fromBaseUnit } from '@shapeshiftoss/utils' +import { useQuery } from '@tanstack/react-query' + +import type { AssetId } from '../types' + +const MARKET_DATA_STALE_TIME = 10 * 60 * 1000 +const MARKET_DATA_GC_TIME = 60 * 60 * 1000 + +type MarketData = { + price: string + marketCap: string + volume: string + changePercent24Hr: number +} + +export type MarketDataById = Record + +type CoinGeckoMarketCap = { + id: string + symbol: string + current_price: number + market_cap: number + total_volume: number + price_change_percentage_24h: number +} + +const COINGECKO_PROXY_URL = 'https://api.proxy.shapeshift.com/api/v1/markets' +const COINGECKO_DIRECT_URL = 'https://api.coingecko.com/api/v3' + +const fetchWithRetry = async (url: string, retries = 2, delay = 1000): Promise => { + for (let i = 0; i <= retries; i++) { + try { + const response = await fetch(url) + if (response.ok) return response + if (response.status === 429 && i < retries) { + await new Promise(r => setTimeout(r, delay * (i + 1))) + continue + } + return response + } catch (error) { + if (i === retries) throw error + await new Promise(r => setTimeout(r, delay * (i + 1))) + } + } + throw new Error('Failed after retries') +} + +const fetchMarketsPage = async ( + baseUrl: string, + page: number, + perPage: number, +): Promise => { + const url = `${baseUrl}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${perPage}&page=${page}&sparkline=false` + const response = await fetchWithRetry(url) + if (!response.ok) return [] + return response.json() +} + +const fetchAllMarketData = async (): Promise => { + const result: MarketDataById = {} + const perPage = 250 + const totalPages = 2 + + let baseUrl = COINGECKO_PROXY_URL + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const testResponse = await fetch( + `${COINGECKO_PROXY_URL}/coins/markets?vs_currency=usd&per_page=1&page=1`, + { signal: controller.signal }, + ).catch(() => null) + + clearTimeout(timeoutId) + + if (!testResponse?.ok) { + baseUrl = COINGECKO_DIRECT_URL + } + + try { + const allData: CoinGeckoMarketCap[] = [] + + for (let page = 1; page <= totalPages; page++) { + const pageData = await fetchMarketsPage(baseUrl, page, perPage) + allData.push(...pageData) + + if (page < totalPages) { + await new Promise(r => setTimeout(r, 500)) + } + } + + for (const asset of allData) { + const assetIds = adapters.coingeckoToAssetIds(asset.id) + if (!assetIds?.length) continue + + const marketData: MarketData = { + price: asset.current_price?.toString() ?? '0', + marketCap: asset.market_cap?.toString() ?? '0', + volume: asset.total_volume?.toString() ?? '0', + changePercent24Hr: asset.price_change_percentage_24h ?? 0, + } + + for (const assetId of assetIds) { + result[assetId] = marketData + } + } + } catch (error) { + console.error('Failed to fetch market data:', error) + } + + return result +} + +export const useAllMarketData = () => { + return useQuery({ + queryKey: ['allMarketData'], + queryFn: fetchAllMarketData, + staleTime: MARKET_DATA_STALE_TIME, + gcTime: MARKET_DATA_GC_TIME, + retry: 1, + retryDelay: 5000, + }) +} + +export const useMarketData = (assetIds: AssetId[]) => { + const { data: allMarketData, ...rest } = useAllMarketData() + + const filteredData = (() => { + if (!allMarketData) return {} + + const result: MarketDataById = {} + for (const assetId of assetIds) { + if (allMarketData[assetId]) { + result[assetId] = allMarketData[assetId] + } + } + return result + })() + + return { data: filteredData, ...rest } +} + +export const useAssetPrice = (assetId: AssetId | undefined) => { + const { data: allMarketData, ...rest } = useAllMarketData() + + return { + data: assetId ? allMarketData?.[assetId]?.price : undefined, + ...rest, + } +} + +export const formatUsdValue = ( + cryptoAmount: string, + precision: number, + usdPrice: string | undefined, +): string => { + if (!usdPrice || usdPrice === '0') return '$0.00' + + const amount = fromBaseUnit(cryptoAmount, precision) + const usdValue = bn(amount).times(usdPrice).toNumber() + + if (usdValue < 0.01) return '< $0.01' + if (usdValue < 1000) return `$${usdValue.toFixed(2)}` + if (usdValue < 1000000) return `$${(usdValue / 1000).toFixed(2)}K` + return `$${(usdValue / 1000000).toFixed(2)}M` +} + +export type { MarketData } diff --git a/packages/swap-widget/src/hooks/useSwapQuote.ts b/packages/swap-widget/src/hooks/useSwapQuote.ts new file mode 100644 index 00000000000..a3018c7c9a1 --- /dev/null +++ b/packages/swap-widget/src/hooks/useSwapQuote.ts @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiClient } from '../api/client' +import type { AssetId, QuoteResponse, SwapperName } from '../types' + +export type UseSwapQuoteParams = { + sellAssetId: AssetId | undefined + buyAssetId: AssetId | undefined + sellAmountCryptoBaseUnit: string | undefined + sendAddress: string | undefined + receiveAddress: string | undefined + swapperName: SwapperName | undefined + slippageTolerancePercentageDecimal?: string + enabled?: boolean +} + +export const useSwapQuote = (apiClient: ApiClient, params: UseSwapQuoteParams) => { + const { + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + sendAddress, + receiveAddress, + swapperName, + slippageTolerancePercentageDecimal, + enabled = true, + } = params + + return useQuery({ + queryKey: [ + 'swapQuote', + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + sendAddress, + receiveAddress, + swapperName, + slippageTolerancePercentageDecimal, + ], + queryFn: (): Promise => { + if ( + !sellAssetId || + !buyAssetId || + !sellAmountCryptoBaseUnit || + !sendAddress || + !receiveAddress || + !swapperName + ) { + return Promise.resolve(null) + } + return apiClient.getQuote({ + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + sendAddress, + receiveAddress, + swapperName, + slippageTolerancePercentageDecimal, + }) + }, + enabled: + enabled && + !!sellAssetId && + !!buyAssetId && + !!sellAmountCryptoBaseUnit && + !!sendAddress && + !!receiveAddress && + !!swapperName, + staleTime: 30_000, + }) +} diff --git a/packages/swap-widget/src/hooks/useSwapRates.ts b/packages/swap-widget/src/hooks/useSwapRates.ts new file mode 100644 index 00000000000..d87c450d788 --- /dev/null +++ b/packages/swap-widget/src/hooks/useSwapRates.ts @@ -0,0 +1,61 @@ +import { bnOrZero } from '@shapeshiftoss/utils' +import { useQuery } from '@tanstack/react-query' + +import type { ApiClient } from '../api/client' +import type { AssetId, SwapperName, TradeRate } from '../types' + +export type UseSwapRatesParams = { + sellAssetId: AssetId | undefined + buyAssetId: AssetId | undefined + sellAmountCryptoBaseUnit: string | undefined + enabled?: boolean + allowedSwapperNames?: SwapperName[] + refetchInterval?: number +} + +export const useSwapRates = (apiClient: ApiClient, params: UseSwapRatesParams) => { + const { + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + enabled = true, + allowedSwapperNames, + refetchInterval = 15_000, + } = params + + return useQuery({ + queryKey: ['swapRates', sellAssetId, buyAssetId, sellAmountCryptoBaseUnit, allowedSwapperNames], + queryFn: async (): Promise => { + if (!sellAssetId || !buyAssetId || !sellAmountCryptoBaseUnit) { + return [] + } + const response = await apiClient.getRates({ + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + }) + + let filteredRates = response.rates.filter( + rate => !rate.error && rate.buyAmountCryptoBaseUnit !== '0', + ) + + if (allowedSwapperNames?.length) { + filteredRates = filteredRates.filter(rate => allowedSwapperNames.includes(rate.swapperName)) + } + + return filteredRates + .map((rate, index) => ({ + ...rate, + id: rate.id ?? `${rate.swapperName}-${index}`, + })) + .sort((a, b) => { + const aAmount = bnOrZero(a.buyAmountCryptoBaseUnit) + const bAmount = bnOrZero(b.buyAmountCryptoBaseUnit) + return bAmount.minus(aAmount).toNumber() + }) + }, + enabled: enabled && !!sellAssetId && !!buyAssetId && !!sellAmountCryptoBaseUnit, + staleTime: 10_000, + refetchInterval, + }) +} diff --git a/packages/swap-widget/src/index.ts b/packages/swap-widget/src/index.ts new file mode 100644 index 00000000000..b7950d25a75 --- /dev/null +++ b/packages/swap-widget/src/index.ts @@ -0,0 +1,43 @@ +export { SwapWidget } from './components/SwapWidget' + +export type { + Asset, + AssetId, + ChainId, + Chain, + TradeRate, + TradeQuote, + SwapWidgetProps, + ThemeMode, + ThemeConfig, +} from './types' + +export { + SwapperName, + isEvmChainId, + getEvmNetworkId, + getChainType, + formatAmount, + parseAmount, + truncateAddress, + EVM_CHAIN_IDS, + UTXO_CHAIN_IDS, + COSMOS_CHAIN_IDS, + OTHER_CHAIN_IDS, +} from './types' + +export { + getBaseAsset, + getChainName, + getChainIcon, + getChainColor, + getExplorerTxLink, +} from './constants/chains' + +export { + useAssets, + useAssetById, + useChains, + useAssetsByChainId, + useAssetSearch, +} from './hooks/useAssets' diff --git a/packages/swap-widget/src/types/index.ts b/packages/swap-widget/src/types/index.ts new file mode 100644 index 00000000000..1d8a25778f4 --- /dev/null +++ b/packages/swap-widget/src/types/index.ts @@ -0,0 +1,265 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { + arbitrumChainId, + arbitrumNovaChainId, + avalancheChainId, + baseChainId, + bchChainId, + bscChainId, + btcChainId, + CHAIN_NAMESPACE, + cosmosChainId, + dogeChainId, + ethChainId, + fromChainId, + gnosisChainId, + hyperEvmChainId, + katanaChainId, + ltcChainId, + mayachainChainId, + monadChainId, + optimismChainId, + plasmaChainId, + polygonChainId, + solanaChainId, + thorchainChainId, +} from '@shapeshiftoss/caip' +import { fromBaseUnit, toBaseUnit } from '@shapeshiftoss/utils' +import type { WalletClient } from 'viem' +import { erc20Abi } from 'viem' + +export type { AssetId, ChainId } + +export enum SwapperName { + Thorchain = 'THORChain', + Mayachain = 'MAYAChain', + CowSwap = 'CoW Swap', + Zrx = '0x', + ArbitrumBridge = 'Arbitrum Bridge', + Portals = 'Portals', + Chainflip = 'Chainflip', + Jupiter = 'Jupiter', + Relay = 'Relay', + ButterSwap = 'ButterSwap', + Bebop = 'Bebop', + NearIntents = 'NEAR Intents', + Cetus = 'Cetus', + Sunio = 'Sun.io', + Avnu = 'AVNU', +} + +export type Chain = { + chainId: ChainId + name: string + icon: string + color: string + nativeAssetId: AssetId +} + +export type Asset = { + assetId: AssetId + chainId: ChainId + symbol: string + name: string + precision: number + icon?: string + color?: string + networkName?: string + networkIcon?: string + explorer?: string + explorerTxLink?: string + explorerAddressLink?: string + relatedAssetKey?: AssetId | null +} + +export type TradeQuoteStep = { + sellAsset: Asset + buyAsset: Asset + sellAmountCryptoBaseUnit: string + buyAmountCryptoBaseUnit: string + rate: string + source: SwapperName + feeData: { + networkFeeCryptoBaseUnit: string + protocolFees?: Record + } + allowanceContract?: string + estimatedExecutionTimeMs?: number +} + +export type TradeQuote = { + id: string + rate: string + swapperName: SwapperName + steps: TradeQuoteStep[] + receiveAddress: string + affiliateBps: string + slippageTolerancePercentageDecimal?: string + isStreaming?: boolean +} + +export type TradeRate = { + swapperName: SwapperName + rate: string + buyAmountCryptoBaseUnit: string + sellAmountCryptoBaseUnit: string + steps: number + estimatedExecutionTimeMs?: number + affiliateBps: string + networkFeeCryptoBaseUnit?: string + error?: { + code: string + message: string + } + id?: string +} + +export type ThemeMode = 'light' | 'dark' + +export type ThemeConfig = { + mode: ThemeMode + accentColor?: string + backgroundColor?: string + cardColor?: string + textColor?: string + borderRadius?: string + fontFamily?: string +} + +export type SwapWidgetProps = { + apiKey?: string + apiBaseUrl?: string + defaultSellAsset?: Asset + defaultBuyAsset?: Asset + disabledChainIds?: ChainId[] + disabledAssetIds?: AssetId[] + allowedChainIds?: ChainId[] + allowedAssetIds?: AssetId[] + allowedSwapperNames?: SwapperName[] + walletClient?: WalletClient + onConnectWallet?: () => void + onSwapSuccess?: (txHash: string) => void + onSwapError?: (error: Error) => void + onAssetSelect?: (type: 'sell' | 'buy', asset: Asset) => void + theme?: ThemeMode | ThemeConfig + defaultSlippage?: string + showPoweredBy?: boolean + enableWalletConnection?: boolean + walletConnectProjectId?: string + defaultReceiveAddress?: string + ratesRefetchInterval?: number +} + +export type RatesResponse = { + rates: TradeRate[] +} + +type TransactionData = { + to: string + data: string + value?: string + gasLimit?: string + chainId?: number + relayId?: string +} + +type QuoteStep = { + transactionData?: TransactionData + relayTransactionMetadata?: TransactionData + butterSwapTransactionMetadata?: TransactionData +} + +export type QuoteResponse = { + quote?: { + steps?: QuoteStep[] + } + transactionData?: TransactionData + steps?: QuoteStep[] + approval?: { + isRequired: boolean + spender: string + } +} + +export { erc20Abi as ERC20_ABI } + +export type AssetsResponse = { + byId: Record + ids: AssetId[] +} + +export const EVM_CHAIN_IDS = { + ethereum: ethChainId, + arbitrum: arbitrumChainId, + optimism: optimismChainId, + polygon: polygonChainId, + base: baseChainId, + avalanche: avalancheChainId, + bsc: bscChainId, + gnosis: gnosisChainId, + arbitrumNova: arbitrumNovaChainId, + monad: monadChainId, + hyperEvm: hyperEvmChainId, + plasma: plasmaChainId, + katana: katanaChainId, +} as const + +export const UTXO_CHAIN_IDS = { + bitcoin: btcChainId, + bitcoinCash: bchChainId, + dogecoin: dogeChainId, + litecoin: ltcChainId, +} as const + +export const COSMOS_CHAIN_IDS = { + cosmos: cosmosChainId, + thorchain: thorchainChainId, + mayachain: mayachainChainId, +} as const + +export const OTHER_CHAIN_IDS = { + solana: solanaChainId, +} as const + +export const isEvmChainId = (chainId: string): boolean => { + const { chainNamespace } = fromChainId(chainId as ChainId) + return chainNamespace === CHAIN_NAMESPACE.Evm +} + +export const getEvmNetworkId = (chainId: string): number => { + const { chainReference } = fromChainId(chainId as ChainId) + return parseInt(chainReference, 10) +} + +export const getChainType = (chainId: string): 'evm' | 'utxo' | 'cosmos' | 'solana' | 'other' => { + const { chainNamespace } = fromChainId(chainId as ChainId) + switch (chainNamespace) { + case CHAIN_NAMESPACE.Evm: + return 'evm' + case CHAIN_NAMESPACE.Utxo: + return 'utxo' + case CHAIN_NAMESPACE.CosmosSdk: + return 'cosmos' + case CHAIN_NAMESPACE.Solana: + return 'solana' + default: + return 'other' + } +} + +export const formatAmount = (amount: string, decimals: number, maxDecimals = 6): string => { + const result = fromBaseUnit(amount, decimals, maxDecimals) + const num = Number(result) + if (num === 0) return '0' + if (num < 0.0001) return '< 0.0001' + return num.toLocaleString(undefined, { maximumFractionDigits: maxDecimals }) +} + +export const parseAmount = (amount: string, decimals: number): string => { + return toBaseUnit(amount, decimals) +} + +export const truncateAddress = (address: string, chars = 4): string => { + if (address.length <= chars * 2 + 2) return address + return `${address.slice(0, chars + 2)}...${address.slice(-chars)}` +} diff --git a/packages/swap-widget/src/utils/addressValidation.ts b/packages/swap-widget/src/utils/addressValidation.ts new file mode 100644 index 00000000000..4c1f19e3246 --- /dev/null +++ b/packages/swap-widget/src/utils/addressValidation.ts @@ -0,0 +1,201 @@ +import { bech32 } from 'bech32' +import { isAddress } from 'viem' + +import type { ChainId } from '../types' +import { COSMOS_CHAIN_IDS, getChainType } from '../types' + +/** + * Validates a Bitcoin address (Legacy, SegWit, Native SegWit, Taproot) + */ +export const isValidBitcoinAddress = (address: string): boolean => { + // Legacy (P2PKH) - starts with 1 + const legacyRegex = /^1[a-km-zA-HJ-NP-Z1-9]{25,34}$/ + // Legacy (P2SH) - starts with 3 + const p2shRegex = /^3[a-km-zA-HJ-NP-Z1-9]{25,34}$/ + // Native SegWit (Bech32) - starts with bc1q + const nativeSegwitRegex = /^bc1q[a-z0-9]{38,58}$/i + // Taproot (Bech32m) - starts with bc1p + const taprootRegex = /^bc1p[a-z0-9]{58}$/i + + return ( + legacyRegex.test(address) || + p2shRegex.test(address) || + nativeSegwitRegex.test(address) || + taprootRegex.test(address) + ) +} + +/** + * Validates a Bitcoin Cash address + */ +export const isValidBitcoinCashAddress = (address: string): boolean => { + // CashAddr format - starts with bitcoincash: or just q/p + const cashAddrRegex = /^(bitcoincash:)?[qp][a-z0-9]{41}$/i + // Legacy format (same as Bitcoin) + const legacyRegex = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/ + + return cashAddrRegex.test(address) || legacyRegex.test(address) +} + +/** + * Validates a Litecoin address + */ +export const isValidLitecoinAddress = (address: string): boolean => { + // Legacy (P2PKH) - starts with L + const legacyRegex = /^L[a-km-zA-HJ-NP-Z1-9]{25,34}$/ + // Legacy (P2SH) - starts with M or 3 + const p2shRegex = /^[M3][a-km-zA-HJ-NP-Z1-9]{25,34}$/ + // Native SegWit (Bech32) - starts with ltc1 + const nativeSegwitRegex = /^ltc1[a-z0-9]{38,58}$/i + + return legacyRegex.test(address) || p2shRegex.test(address) || nativeSegwitRegex.test(address) +} + +/** + * Validates a Dogecoin address + */ +export const isValidDogecoinAddress = (address: string): boolean => { + // P2PKH - starts with D + const p2pkhRegex = /^D[5-9A-HJ-NP-U][a-km-zA-HJ-NP-Z1-9]{32}$/ + // P2SH - starts with 9 or A + const p2shRegex = /^[9A][a-km-zA-HJ-NP-Z1-9]{33}$/ + + return p2pkhRegex.test(address) || p2shRegex.test(address) +} + +export const isValidCosmosAddress = (address: string, expectedPrefix?: string): boolean => { + try { + const decoded = bech32.decode(address) + if (expectedPrefix && decoded.prefix !== expectedPrefix) { + return false + } + return true + } catch { + return false + } +} + +/** + * Gets the expected bech32 prefix for a Cosmos chain + */ +const getCosmosPrefix = (chainId: ChainId): string | undefined => { + const prefixMap: Record = { + [COSMOS_CHAIN_IDS.cosmos]: 'cosmos', + [COSMOS_CHAIN_IDS.thorchain]: 'thor', + [COSMOS_CHAIN_IDS.mayachain]: 'maya', + } + return prefixMap[chainId] +} + +/** + * Validates a Solana address (base58, 32-44 chars) + */ +export const isValidSolanaAddress = (address: string): boolean => { + // Solana addresses are base58 encoded, typically 32-44 characters + // Base58 alphabet excludes 0, O, I, l + const base58Regex = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ + return base58Regex.test(address) +} + +/** + * Validates an address for a specific chain + */ +export const validateAddress = ( + address: string, + chainId: ChainId, +): { valid: boolean; error?: string } => { + if (!address || address.trim() === '') { + return { valid: false, error: 'Address is required' } + } + + const trimmedAddress = address.trim() + const chainType = getChainType(chainId) + + switch (chainType) { + case 'evm': + if (!isAddress(trimmedAddress, { strict: false })) { + return { valid: false, error: 'Invalid EVM address' } + } + break + + case 'utxo': + // Determine specific UTXO chain + if (chainId.includes('000000000019d6689c085ae165831e93')) { + // Bitcoin + if (!isValidBitcoinAddress(trimmedAddress)) { + return { valid: false, error: 'Invalid Bitcoin address' } + } + } else if (chainId.includes('000000000000000000651ef99cb9fcbe')) { + // Bitcoin Cash + if (!isValidBitcoinCashAddress(trimmedAddress)) { + return { valid: false, error: 'Invalid Bitcoin Cash address' } + } + } else if (chainId.includes('12a765e31ffd4059bada1e25190f6e98')) { + // Litecoin + if (!isValidLitecoinAddress(trimmedAddress)) { + return { valid: false, error: 'Invalid Litecoin address' } + } + } else if (chainId.includes('00000000001a91e3dace36e2be3bf030')) { + // Dogecoin + if (!isValidDogecoinAddress(trimmedAddress)) { + return { valid: false, error: 'Invalid Dogecoin address' } + } + } else { + return { valid: false, error: 'Unsupported UTXO chain' } + } + break + + case 'cosmos': { + const expectedPrefix = getCosmosPrefix(chainId) + if (!isValidCosmosAddress(trimmedAddress, expectedPrefix)) { + const chainName = expectedPrefix + ? expectedPrefix.charAt(0).toUpperCase() + expectedPrefix.slice(1) + : 'Cosmos' + return { valid: false, error: `Invalid ${chainName} address` } + } + break + } + + case 'solana': + if (!isValidSolanaAddress(trimmedAddress)) { + return { valid: false, error: 'Invalid Solana address' } + } + break + + default: + return { valid: false, error: 'Unsupported chain type' } + } + + return { valid: true } +} + +/** + * Gets the expected address format hint for a chain + */ +export const getAddressFormatHint = (chainId: ChainId): string => { + const chainType = getChainType(chainId) + + switch (chainType) { + case 'evm': + return '0x...' + case 'utxo': + if (chainId.includes('000000000019d6689c085ae165831e93')) { + return 'bc1... or 1... or 3...' + } else if (chainId.includes('000000000000000000651ef99cb9fcbe')) { + return 'bitcoincash:q... or 1...' + } else if (chainId.includes('12a765e31ffd4059bada1e25190f6e98')) { + return 'ltc1... or L... or M...' + } else if (chainId.includes('00000000001a91e3dace36e2be3bf030')) { + return 'D...' + } + return 'Enter address' + case 'cosmos': { + const prefix = getCosmosPrefix(chainId) + return prefix ? `${prefix}1...` : 'Enter address' + } + case 'solana': + return 'Base58 address' + default: + return 'Enter address' + } +} diff --git a/packages/swap-widget/src/utils/redirect.ts b/packages/swap-widget/src/utils/redirect.ts new file mode 100644 index 00000000000..a24a03f5894 --- /dev/null +++ b/packages/swap-widget/src/utils/redirect.ts @@ -0,0 +1,45 @@ +import type { Asset, AssetId } from '../types' +import { isEvmChainId } from '../types' + +const SHAPESHIFT_APP_URL = 'https://app.shapeshift.com' + +export type RedirectParams = { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmount?: string +} + +export const buildShapeShiftTradeUrl = (params: RedirectParams): string => { + const url = new URL(`${SHAPESHIFT_APP_URL}/trade`) + url.searchParams.set('sellAssetId', params.sellAssetId) + url.searchParams.set('buyAssetId', params.buyAssetId) + if (params.sellAmount) { + url.searchParams.set('sellAmount', params.sellAmount) + } + return url.toString() +} + +export const redirectToShapeShift = (params: RedirectParams): void => { + const url = buildShapeShiftTradeUrl(params) + window.open(url, '_blank', 'noopener,noreferrer') +} + +export type ChainType = 'evm' | 'utxo' | 'cosmos' | 'solana' | 'other' + +export const getChainTypeFromAsset = (asset: Asset): ChainType => { + const chainId = asset.chainId + + if (isEvmChainId(chainId)) return 'evm' + if (chainId.startsWith('bip122:')) return 'utxo' + if (chainId.startsWith('cosmos:')) return 'cosmos' + if (chainId.startsWith('solana:')) return 'solana' + + return 'other' +} + +export const canExecuteInWidget = (sellAsset: Asset, buyAsset: Asset): boolean => { + const sellChainType = getChainTypeFromAsset(sellAsset) + const buyChainType = getChainTypeFromAsset(buyAsset) + + return sellChainType === 'evm' && buyChainType === 'evm' +} diff --git a/packages/swap-widget/src/vite-env.d.ts b/packages/swap-widget/src/vite-env.d.ts new file mode 100644 index 00000000000..2b2ebad0ae3 --- /dev/null +++ b/packages/swap-widget/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface EthereumProvider { + request: (args: { method: string; params?: unknown[] }) => Promise + on: (event: string, callback: (accounts: string[]) => void) => void +} + +interface Window { + ethereum?: EthereumProvider +} diff --git a/packages/swap-widget/tsconfig.json b/packages/swap-widget/tsconfig.json new file mode 100644 index 00000000000..f50b75c5f0c --- /dev/null +++ b/packages/swap-widget/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/swap-widget/tsconfig.node.json b/packages/swap-widget/tsconfig.node.json new file mode 100644 index 00000000000..42872c59f5b --- /dev/null +++ b/packages/swap-widget/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/swap-widget/vite.config.ts b/packages/swap-widget/vite.config.ts new file mode 100644 index 00000000000..2a675c9a009 --- /dev/null +++ b/packages/swap-widget/vite.config.ts @@ -0,0 +1,83 @@ +import react from '@vitejs/plugin-react' +import type { PluginOption } from 'vite' +import { defineConfig } from 'vite' +import { nodePolyfills } from 'vite-plugin-node-polyfills' + +const isLibBuild = process.env.BUILD_LIB === 'true' + +const libExternals = [ + 'react', + 'react-dom', + 'viem', + 'wagmi', + '@rainbow-me/rainbowkit', + '@tanstack/react-query', +] + +const defineGlobalThis: PluginOption = { + name: 'define-global-this', + enforce: 'pre', + transform(code) { + if (code.includes('vite-plugin-node-polyfills')) { + return `if (typeof globalThis === 'undefined') { + globalThis = typeof window !== 'undefined' ? window : + typeof global !== 'undefined' ? global : + typeof self !== 'undefined' ? self : this; + };${code}` + } + }, +} + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + plugins: [ + defineGlobalThis, + nodePolyfills({ + globals: { + Buffer: true, + global: true, + process: true, + }, + protocolImports: true, + }) as unknown as PluginOption, + react(), + ], + define: { + 'process.env': {}, + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + }, + server: { + port: 3001, + open: false, + }, + preview: { + port: Number(process.env.PORT) || 3000, + host: true, + }, + build: isLibBuild + ? { + lib: { + entry: 'src/index.ts', + name: 'SwapWidget', + fileName: 'index', + }, + rollupOptions: { + external: libExternals, + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + }, + } + : { + outDir: 'dist', + }, +}) diff --git a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts index 7ceefbc5cf2..734a5aed783 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts @@ -2,13 +2,13 @@ import { getQuotes } from '@avnu/avnu-sdk' import { bn } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import { getTreasuryAddressFromChainId } from 'packages/swapper/src/swappers/utils/helpers/helpers' import { v4 as uuid } from 'uuid' import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' import type { CommonTradeQuoteInput, SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { getTreasuryAddressFromChainId } from '../../utils/helpers/helpers' import { AVNU_SUPPORTED_CHAIN_IDS } from '../utils/constants' import { getTokenAddress } from '../utils/helpers' diff --git a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeRate.ts b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeRate.ts index 18858f3ea78..3f32139995a 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeRate.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeRate.ts @@ -2,13 +2,13 @@ import { getQuotes } from '@avnu/avnu-sdk' import { bn } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import { getTreasuryAddressFromChainId } from 'packages/swapper/src/swappers/utils/helpers/helpers' import { v4 as uuid } from 'uuid' import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' import type { GetTradeRateInput, SwapErrorRight, SwapperDeps, TradeRate } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { getTreasuryAddressFromChainId } from '../../utils/helpers/helpers' import { AVNU_SUPPORTED_CHAIN_IDS } from '../utils/constants' import { getTokenAddress } from '../utils/helpers' diff --git a/railway.json b/railway.json new file mode 100644 index 00000000000..46cd2ac60bd --- /dev/null +++ b/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "packages/swap-widget/Dockerfile" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/yarn.lock b/yarn.lock index 85779c4aa6f..b9b94b3f66c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -324,7 +324,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.28.4": +"@babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -5147,7 +5147,7 @@ __metadata: languageName: node linkType: hard -"@emotion/hash@npm:^0.9.2": +"@emotion/hash@npm:^0.9.0, @emotion/hash@npm:^0.9.2": version: 0.9.2 resolution: "@emotion/hash@npm:0.9.2" checksum: 379bde2830ccb0328c2617ec009642321c0e009a46aa383dfbe75b679c6aea977ca698c832d225a893901f29d7b3eef0e38cf341f560f6b2b56f1ff23c172387 @@ -5346,6 +5346,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/aix-ppc64@npm:0.24.2" @@ -5367,6 +5374,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/android-arm64@npm:0.24.2" @@ -5388,6 +5402,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/android-arm@npm:0.24.2" @@ -5409,6 +5430,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/android-x64@npm:0.24.2" @@ -5430,6 +5458,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/darwin-arm64@npm:0.24.2" @@ -5451,6 +5486,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/darwin-x64@npm:0.24.2" @@ -5472,6 +5514,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/freebsd-arm64@npm:0.24.2" @@ -5493,6 +5542,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/freebsd-x64@npm:0.24.2" @@ -5514,6 +5570,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-arm64@npm:0.24.2" @@ -5535,6 +5598,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-arm@npm:0.24.2" @@ -5556,6 +5626,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-ia32@npm:0.24.2" @@ -5577,6 +5654,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-loong64@npm:0.24.2" @@ -5598,6 +5682,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-mips64el@npm:0.24.2" @@ -5619,6 +5710,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-ppc64@npm:0.24.2" @@ -5640,6 +5738,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-riscv64@npm:0.24.2" @@ -5661,6 +5766,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-s390x@npm:0.24.2" @@ -5682,6 +5794,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/linux-x64@npm:0.24.2" @@ -5724,6 +5843,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/netbsd-x64@npm:0.24.2" @@ -5766,6 +5892,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/openbsd-x64@npm:0.24.2" @@ -5787,6 +5920,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/sunos-x64@npm:0.24.2" @@ -5808,6 +5948,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/win32-arm64@npm:0.24.2" @@ -5829,6 +5976,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/win32-ia32@npm:0.24.2" @@ -5850,6 +6004,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/win32-x64@npm:0.24.2" @@ -10434,6 +10595,27 @@ __metadata: languageName: node linkType: hard +"@rainbow-me/rainbowkit@npm:2.1.7": + version: 2.1.7 + resolution: "@rainbow-me/rainbowkit@npm:2.1.7" + dependencies: + "@vanilla-extract/css": 1.15.5 + "@vanilla-extract/dynamic": 2.1.2 + "@vanilla-extract/sprinkles": 1.6.3 + clsx: 2.1.1 + qrcode: 1.5.4 + react-remove-scroll: 2.6.0 + ua-parser-js: ^1.0.37 + peerDependencies: + "@tanstack/react-query": ">=5.0.0" + react: ">=18" + react-dom: ">=18" + viem: 2.x + wagmi: ^2.9.0 + checksum: 67dda23511e052e9703aa83446b8e05a1a79f7399c40854e01b5f2c0c5d2e187d3deb02e49810d623e08cd01322b495389d7a91a4535d938a7b23f243ffec0f7 + languageName: node + linkType: hard + "@randlabs/communication-bridge@npm:1.0.1": version: 1.0.1 resolution: "@randlabs/communication-bridge@npm:1.0.1" @@ -10668,6 +10850,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-beta.27": + version: 1.0.0-beta.27 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" + checksum: b57d8de44534bdbb92e9dda70a29c5b3cb7a13cc3a2efaaf9d27923ca23e2526e9470939fe1d7c6a96623da20aaf96a1517502e1d9a6d4f98fbb544cf502600a + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.43": version: 1.0.0-beta.43 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.43" @@ -10721,6 +10910,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.55.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-android-arm64@npm:4.34.8" @@ -10735,6 +10931,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-android-arm64@npm:4.55.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-darwin-arm64@npm:4.34.8" @@ -10749,6 +10952,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.55.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-darwin-x64@npm:4.34.8" @@ -10763,6 +10973,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.55.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-freebsd-arm64@npm:4.34.8" @@ -10777,6 +10994,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.55.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-freebsd-x64@npm:4.34.8" @@ -10791,6 +11015,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.55.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.8" @@ -10805,6 +11036,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.55.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.34.8" @@ -10819,6 +11057,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.55.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.34.8" @@ -10833,6 +11078,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.55.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.34.8" @@ -10847,6 +11099,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.55.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.55.1" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.55.1" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.8" @@ -10875,6 +11148,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.55.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.55.1" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.34.8" @@ -10889,6 +11176,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.55.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1" @@ -10896,6 +11190,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.55.1" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.34.8" @@ -10910,6 +11211,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.55.1" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.34.8" @@ -10924,6 +11232,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.55.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-linux-x64-musl@npm:4.34.8" @@ -10938,6 +11253,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.55.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-openbsd-x64@npm:4.55.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-openharmony-arm64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1" @@ -10945,6 +11274,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-openharmony-arm64@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.55.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.34.8" @@ -10959,6 +11295,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.55.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.34.8" @@ -10973,6 +11316,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.55.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.55.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.34.8": version: 4.34.8 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.34.8" @@ -10987,6 +11344,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.55.1": + version: 4.55.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.55.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rrweb/types@npm:^2.0.0-alpha.18": version: 2.0.0-alpha.18 resolution: "@rrweb/types@npm:2.0.0-alpha.18" @@ -11391,7 +11755,7 @@ __metadata: languageName: node linkType: hard -"@shapeshiftoss/caip@^8.15.0, @shapeshiftoss/caip@workspace:^, @shapeshiftoss/caip@workspace:packages/caip": +"@shapeshiftoss/caip@^8.15.0, @shapeshiftoss/caip@npm:@shapeshiftoss/caip@8.16.6, @shapeshiftoss/caip@workspace:^, @shapeshiftoss/caip@workspace:packages/caip": version: 0.0.0-use.local resolution: "@shapeshiftoss/caip@workspace:packages/caip" dependencies: @@ -11961,6 +12325,33 @@ __metadata: languageName: node linkType: hard +"@shapeshiftoss/swap-widget@workspace:packages/swap-widget": + version: 0.0.0-use.local + resolution: "@shapeshiftoss/swap-widget@workspace:packages/swap-widget" + dependencies: + "@rainbow-me/rainbowkit": 2.1.7 + "@shapeshiftoss/caip": "npm:@shapeshiftoss/caip@8.16.6" + "@shapeshiftoss/utils": "npm:@shapeshiftoss/utils@1.0.5" + "@tanstack/react-query": ^5.69.0 + "@types/react": ^19.0.0 + "@types/react-dom": ^19.0.0 + "@vitejs/plugin-react": ^4.2.0 + bech32: ^2.0.0 + p-queue: ^8.0.1 + react: ^19.0.0 + react-dom: ^19.0.0 + react-virtuoso: ^4.7.11 + typescript: ^5.2.2 + viem: 2.40.3 + vite: ^5.0.0 + vite-plugin-node-polyfills: ^0.23.0 + wagmi: ^2.9.2 + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + languageName: unknown + linkType: soft + "@shapeshiftoss/swapper@workspace:^, @shapeshiftoss/swapper@workspace:packages/swapper": version: 0.0.0-use.local resolution: "@shapeshiftoss/swapper@workspace:packages/swapper" @@ -12058,7 +12449,7 @@ __metadata: languageName: node linkType: hard -"@shapeshiftoss/utils@workspace:^, @shapeshiftoss/utils@workspace:packages/utils": +"@shapeshiftoss/utils@npm:@shapeshiftoss/utils@1.0.5, @shapeshiftoss/utils@workspace:^, @shapeshiftoss/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@shapeshiftoss/utils@workspace:packages/utils" dependencies: @@ -15220,6 +15611,51 @@ __metadata: languageName: node linkType: hard +"@vanilla-extract/css@npm:1.15.5": + version: 1.15.5 + resolution: "@vanilla-extract/css@npm:1.15.5" + dependencies: + "@emotion/hash": ^0.9.0 + "@vanilla-extract/private": ^1.0.6 + css-what: ^6.1.0 + cssesc: ^3.0.0 + csstype: ^3.0.7 + dedent: ^1.5.3 + deep-object-diff: ^1.1.9 + deepmerge: ^4.2.2 + lru-cache: ^10.4.3 + media-query-parser: ^2.0.2 + modern-ahocorasick: ^1.0.0 + picocolors: ^1.0.0 + checksum: 0c260e55a1648a827df74cae4475a1a61767e4ef3a7a3a299853ae3f77ed220d7a4b604737886140ea9e72a379eda4ee45b7349a4651cf3d5a4f2c8697448d6d + languageName: node + linkType: hard + +"@vanilla-extract/dynamic@npm:2.1.2": + version: 2.1.2 + resolution: "@vanilla-extract/dynamic@npm:2.1.2" + dependencies: + "@vanilla-extract/private": ^1.0.6 + checksum: ec6ec9b02c7ec8a9d60aebf63225fd3f930c06ad824321f03f683f1948eb6d4e554d934303da140b3230b4af2fa15bab494c6da2a3b9a172e4118c245b4f942a + languageName: node + linkType: hard + +"@vanilla-extract/private@npm:^1.0.6": + version: 1.0.9 + resolution: "@vanilla-extract/private@npm:1.0.9" + checksum: 0f47b9faa9dc40c6cf7d2b48c16a6398b870c448db9276850449ecab1059ab10b354c67a574e0b2f6cb54054a20dfa42cf30f2f39e55dc4ab7c6cdc0ffbf7bbe + languageName: node + linkType: hard + +"@vanilla-extract/sprinkles@npm:1.6.3": + version: 1.6.3 + resolution: "@vanilla-extract/sprinkles@npm:1.6.3" + peerDependencies: + "@vanilla-extract/css": ^1.0.0 + checksum: 7eb4fe0f1a6048bf5ffb5ffab964c2d127ff95244da79dca2e448af380b591c7af3b4f63ab243584baa8a42c7694d8fe9eeb366587a2da381a481fe1a9e02af8 + languageName: node + linkType: hard + "@visx/annotation@npm:3.12.0": version: 3.12.0 resolution: "@visx/annotation@npm:3.12.0" @@ -15658,6 +16094,22 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:^4.2.0": + version: 4.7.0 + resolution: "@vitejs/plugin-react@npm:4.7.0" + dependencies: + "@babel/core": ^7.28.0 + "@babel/plugin-transform-react-jsx-self": ^7.27.1 + "@babel/plugin-transform-react-jsx-source": ^7.27.1 + "@rolldown/pluginutils": 1.0.0-beta.27 + "@types/babel__core": ^7.20.5 + react-refresh: ^0.17.0 + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 3e3c4c58f65e8041c4891736613732d572f232bc6b039e74ea00554b94b8010ac246e2f6e099e8b6c474d9c1741e2b166b48fa47fe0093312beefa99a1b13d00 + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:^5.1.0": version: 5.1.0 resolution: "@vitejs/plugin-react@npm:5.1.0" @@ -19737,6 +20189,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:2.1.1, clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: acd3e1ab9d8a433ecb3cc2f6a05ab95fe50b4a3cfc5ba47abb6cbf3754585fcb87b84e90c822a1f256c4198e3b41c7f6c391577ffc8678ad587fc0976b24fd57 + languageName: node + linkType: hard + "clsx@npm:^1.1.0, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" @@ -19744,13 +20203,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^2.1.1": - version: 2.1.1 - resolution: "clsx@npm:2.1.1" - checksum: acd3e1ab9d8a433ecb3cc2f6a05ab95fe50b4a3cfc5ba47abb6cbf3754585fcb87b84e90c822a1f256c4198e3b41c7f6c391577ffc8678ad587fc0976b24fd57 - languageName: node - linkType: hard - "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -20474,6 +20926,22 @@ __metadata: languageName: node linkType: hard +"css-what@npm:^6.1.0": + version: 6.2.2 + resolution: "css-what@npm:6.2.2" + checksum: 4d1f07b348a638e1f8b4c72804a1e93881f35e0f541256aec5ac0497c5855df7db7ab02da030de950d4813044f6d029a14ca657e0f92c3987e4b604246235b2b + languageName: node + linkType: hard + +"cssesc@npm:^3.0.0": + version: 3.0.0 + resolution: "cssesc@npm:3.0.0" + bin: + cssesc: bin/cssesc + checksum: f8c4ababffbc5e2ddf2fa9957dda1ee4af6048e22aeda1869d0d00843223c1b13ad3f5d88b51caa46c994225eacb636b764eb807a8883e2fb6f99b4f4e8c48b2 + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.0 resolution: "csstype@npm:3.1.0" @@ -20481,6 +20949,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.0.7": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: cb882521b3398958a1ce6ca98c011aec0bde1c77ecaf8a1dd4db3b112a189939beae3b1308243b2fe50fc27eb3edeb0f73a5a4d91d928765dc6d5ecc7bda92ee + languageName: node + linkType: hard + "csstype@npm:^3.1.2": version: 3.1.2 resolution: "csstype@npm:3.1.2" @@ -20908,6 +21383,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.5.3": + version: 1.7.1 + resolution: "dedent@npm:1.7.1" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 66dc34f61dabc85597a95ce8678c93f0793ec437cc6510e0e6c14da159ce15c6209dee483aa3cccb3238a2f708382c4d26eeb1a47a4c1831a0b7bb56873041cf + languageName: node + linkType: hard + "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" @@ -20929,6 +21416,13 @@ __metadata: languageName: node linkType: hard +"deep-object-diff@npm:^1.1.9": + version: 1.1.9 + resolution: "deep-object-diff@npm:1.1.9" + checksum: ecd42455e4773f653595d28070295e7aaa8402db5f8ab21d0bec115a7cb4de5e207a5665514767da5f025c96597f1d3a0a4888aeb4dd49e03c996871a3aa05ef + languageName: node + linkType: hard + "deepmerge@npm:^4.2.2": version: 4.2.2 resolution: "deepmerge@npm:4.2.2" @@ -22227,6 +22721,86 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.21.3": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": 0.21.5 + "@esbuild/android-arm": 0.21.5 + "@esbuild/android-arm64": 0.21.5 + "@esbuild/android-x64": 0.21.5 + "@esbuild/darwin-arm64": 0.21.5 + "@esbuild/darwin-x64": 0.21.5 + "@esbuild/freebsd-arm64": 0.21.5 + "@esbuild/freebsd-x64": 0.21.5 + "@esbuild/linux-arm": 0.21.5 + "@esbuild/linux-arm64": 0.21.5 + "@esbuild/linux-ia32": 0.21.5 + "@esbuild/linux-loong64": 0.21.5 + "@esbuild/linux-mips64el": 0.21.5 + "@esbuild/linux-ppc64": 0.21.5 + "@esbuild/linux-riscv64": 0.21.5 + "@esbuild/linux-s390x": 0.21.5 + "@esbuild/linux-x64": 0.21.5 + "@esbuild/netbsd-x64": 0.21.5 + "@esbuild/openbsd-x64": 0.21.5 + "@esbuild/sunos-x64": 0.21.5 + "@esbuild/win32-arm64": 0.21.5 + "@esbuild/win32-ia32": 0.21.5 + "@esbuild/win32-x64": 0.21.5 + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 2911c7b50b23a9df59a7d6d4cdd3a4f85855787f374dce751148dbb13305e0ce7e880dde1608c2ab7a927fc6cec3587b80995f7fc87a64b455f8b70b55fd8ec1 + languageName: node + linkType: hard + "esbuild@npm:^0.24.0": version: 0.24.2 resolution: "esbuild@npm:0.24.2" @@ -27579,6 +28153,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.4.3": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 6476138d2125387a6d20f100608c2583d415a4f64a0fecf30c9e2dda976614f09cad4baa0842447bd37dd459a7bd27f57d9d8f8ce558805abd487c583f3d774a + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -27742,6 +28323,15 @@ __metadata: languageName: node linkType: hard +"media-query-parser@npm:^2.0.2": + version: 2.0.2 + resolution: "media-query-parser@npm:2.0.2" + dependencies: + "@babel/runtime": ^7.12.5 + checksum: 8ef956d9e63fe6f4041988beda69843b3a6bb48228ea2923a066f6e7c8f7c5dba75fae357318c48a97ed5beae840b8425cb7e727fc1bb77acc65f2005f8945ab + languageName: node + linkType: hard + "memdown@npm:^1.0.0": version: 1.4.1 resolution: "memdown@npm:1.4.1" @@ -28226,6 +28816,13 @@ __metadata: languageName: node linkType: hard +"modern-ahocorasick@npm:^1.0.0": + version: 1.1.0 + resolution: "modern-ahocorasick@npm:1.1.0" + checksum: 78b99840c9af086c1e36a594ee85bebd8c19d48e2ef31a67d1bad0e673ac12fc931e5961abb5b16daaf820af4923e700f76b1793b7413e18782230162866a0af + languageName: node + linkType: hard + "module-alias@npm:^2.2.3": version: 2.2.3 resolution: "module-alias@npm:2.2.3" @@ -30056,7 +30653,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.38": +"postcss@npm:^8.4.38, postcss@npm:^8.4.43": version: 8.5.6 resolution: "postcss@npm:8.5.6" dependencies: @@ -30486,6 +31083,19 @@ pvutils@latest: languageName: node linkType: hard +"qrcode@npm:1.5.4": + version: 1.5.4 + resolution: "qrcode@npm:1.5.4" + dependencies: + dijkstrajs: ^1.0.1 + pngjs: ^5.0.0 + yargs: ^15.3.1 + bin: + qrcode: bin/qrcode + checksum: 0a162822e12c02b0333315462fd4ccad22255002130f86806773be7592aec5ef295efaffa3eb148cbf00e290839c7b610f63b0d62a0c5efc5bc52a68f4189684 + languageName: node + linkType: hard + "qs@npm:^6.10.3, qs@npm:^6.5.2": version: 6.11.0 resolution: "qs@npm:6.11.0" @@ -30912,6 +31522,13 @@ pvutils@latest: languageName: node linkType: hard +"react-refresh@npm:^0.17.0": + version: 0.17.0 + resolution: "react-refresh@npm:0.17.0" + checksum: e9d23a70543edde879263976d7909cd30c6f698fa372a1240142cf7c8bf99e0396378b9c07c2d39c3a10261d7ba07dc49f990cd8f1ac7b88952e99040a0be5e9 + languageName: node + linkType: hard + "react-refresh@npm:^0.18.0": version: 0.18.0 resolution: "react-refresh@npm:0.18.0" @@ -30919,7 +31536,7 @@ pvutils@latest: languageName: node linkType: hard -"react-remove-scroll-bar@npm:^2.3.7": +"react-remove-scroll-bar@npm:^2.3.6, react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8" dependencies: @@ -30935,6 +31552,25 @@ pvutils@latest: languageName: node linkType: hard +"react-remove-scroll@npm:2.6.0": + version: 2.6.0 + resolution: "react-remove-scroll@npm:2.6.0" + dependencies: + react-remove-scroll-bar: ^2.3.6 + react-style-singleton: ^2.2.1 + tslib: ^2.1.0 + use-callback-ref: ^1.3.0 + use-sidecar: ^1.1.2 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: e7ad2383ce20d63cf28f3ed14e63f684e139301fc4a5c1573da330d4465b733e6084c33b2bfcaee448c9b1df0e37993a15d6cba8a1dd80fe631f803e48e9f798 + languageName: node + linkType: hard + "react-remove-scroll@npm:^2.5.7, react-remove-scroll@npm:^2.6.2": version: 2.6.3 resolution: "react-remove-scroll@npm:2.6.3" @@ -31046,7 +31682,7 @@ pvutils@latest: languageName: node linkType: hard -"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": +"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" dependencies: @@ -31990,6 +32626,96 @@ pvutils@latest: languageName: node linkType: hard +"rollup@npm:^4.20.0": + version: 4.55.1 + resolution: "rollup@npm:4.55.1" + dependencies: + "@rollup/rollup-android-arm-eabi": 4.55.1 + "@rollup/rollup-android-arm64": 4.55.1 + "@rollup/rollup-darwin-arm64": 4.55.1 + "@rollup/rollup-darwin-x64": 4.55.1 + "@rollup/rollup-freebsd-arm64": 4.55.1 + "@rollup/rollup-freebsd-x64": 4.55.1 + "@rollup/rollup-linux-arm-gnueabihf": 4.55.1 + "@rollup/rollup-linux-arm-musleabihf": 4.55.1 + "@rollup/rollup-linux-arm64-gnu": 4.55.1 + "@rollup/rollup-linux-arm64-musl": 4.55.1 + "@rollup/rollup-linux-loong64-gnu": 4.55.1 + "@rollup/rollup-linux-loong64-musl": 4.55.1 + "@rollup/rollup-linux-ppc64-gnu": 4.55.1 + "@rollup/rollup-linux-ppc64-musl": 4.55.1 + "@rollup/rollup-linux-riscv64-gnu": 4.55.1 + "@rollup/rollup-linux-riscv64-musl": 4.55.1 + "@rollup/rollup-linux-s390x-gnu": 4.55.1 + "@rollup/rollup-linux-x64-gnu": 4.55.1 + "@rollup/rollup-linux-x64-musl": 4.55.1 + "@rollup/rollup-openbsd-x64": 4.55.1 + "@rollup/rollup-openharmony-arm64": 4.55.1 + "@rollup/rollup-win32-arm64-msvc": 4.55.1 + "@rollup/rollup-win32-ia32-msvc": 4.55.1 + "@rollup/rollup-win32-x64-gnu": 4.55.1 + "@rollup/rollup-win32-x64-msvc": 4.55.1 + "@types/estree": 1.0.8 + fsevents: ~2.3.2 + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: fd5374cd7e6046404d59d64e1346821fee0900bc9cac021078bfdd342fc54305defba882fb53e6543b5c17ce4573b30fb45ee28b9a4122548358004efdc550d2 + languageName: node + linkType: hard + "rollup@npm:^4.30.1": version: 4.34.8 resolution: "rollup@npm:4.34.8" @@ -34795,6 +35521,15 @@ pvutils@latest: languageName: node linkType: hard +"ua-parser-js@npm:^1.0.37": + version: 1.0.41 + resolution: "ua-parser-js@npm:1.0.41" + bin: + ua-parser-js: script/cli.js + checksum: a57c258ea3a242ade7601460ddf9a7e990d8d8bffc15df2ca87057a81993ca19f5045432c744d07bf2d9f280665d84aebb08630c5af5bea3922fdbe8f6fe6cb0 + languageName: node + linkType: hard + "ua-parser-js@npm:^2.0.4": version: 2.0.6 resolution: "ua-parser-js@npm:2.0.6" @@ -35219,7 +35954,7 @@ pvutils@latest: languageName: node linkType: hard -"use-callback-ref@npm:^1.3.3": +"use-callback-ref@npm:^1.3.0, use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3" dependencies: @@ -35281,7 +36016,7 @@ pvutils@latest: languageName: node linkType: hard -"use-sidecar@npm:^1.1.3": +"use-sidecar@npm:^1.1.2, use-sidecar@npm:^1.1.3": version: 1.1.3 resolution: "use-sidecar@npm:1.1.3" dependencies: @@ -35859,6 +36594,49 @@ pvutils@latest: languageName: node linkType: hard +"vite@npm:^5.0.0": + version: 5.4.21 + resolution: "vite@npm:5.4.21" + dependencies: + esbuild: ^0.21.3 + fsevents: ~2.3.3 + postcss: ^8.4.43 + rollup: ^4.20.0 + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 7177fa03cff6a382f225290c9889a0d0e944d17eab705bcba89b58558a6f7adfa1f47e469b88f42a044a0eb40c12a1bf68b3cb42abb5295d04f9d7d4dd320837 + languageName: node + linkType: hard + "vite@npm:^5.0.0 || ^6.0.0": version: 6.2.6 resolution: "vite@npm:6.2.6"