diff --git a/.env b/.env index 123de951118..dacbf5ca7c3 100644 --- a/.env +++ b/.env @@ -104,7 +104,7 @@ VITE_FEATURE_ABSTRACT=false VITE_FEATURE_HEMI=false VITE_FEATURE_SONIC=false VITE_FEATURE_UNICHAIN=false -VITE_FEATURE_BOB=false +VITE_FEATURE_BOB=true VITE_FEATURE_MODE=false VITE_FEATURE_SONEIUM=false VITE_FEATURE_SEI=false @@ -116,7 +116,7 @@ VITE_FEATURE_TON=true VITE_FEATURE_EARN_TAB=true VITE_FEATURE_ACROSS_SWAP=true VITE_FEATURE_DEBRIDGE_SWAP=true -VITE_FEATURE_BOB_GATEWAY_SWAP=false +VITE_FEATURE_BOB_GATEWAY_SWAP=true VITE_FEATURE_USERBACK=true VITE_FEATURE_AGENTIC_CHAT=false VITE_FEATURE_MM_NATIVE_MULTICHAIN=false diff --git a/.env.development b/.env.development index e887b88a788..c6399c767d6 100644 --- a/.env.development +++ b/.env.development @@ -27,8 +27,6 @@ VITE_FEATURE_WORLDCHAIN=true VITE_FEATURE_HEMI=true VITE_FEATURE_SONIC=true VITE_FEATURE_UNICHAIN=true -VITE_FEATURE_BOB=true -VITE_FEATURE_BOB_GATEWAY_SWAP=true VITE_FEATURE_MODE=true VITE_FEATURE_SONEIUM=true VITE_FEATURE_TON=true diff --git a/packages/swap-widget/package.json b/packages/swap-widget/package.json index 0eb45951e08..757e641b7cc 100644 --- a/packages/swap-widget/package.json +++ b/packages/swap-widget/package.json @@ -1,6 +1,6 @@ { "name": "@shapeshiftoss/swap-widget", - "version": "0.3.0", + "version": "0.4.0", "description": "Embeddable swap widget using ShapeShift API", "repository": "https://github.com/shapeshift/web", "license": "MIT", diff --git a/packages/swap-widget/src/components/InputStep.tsx b/packages/swap-widget/src/components/InputStep.tsx index 84a5f3e2515..3aa4ed248bf 100644 --- a/packages/swap-widget/src/components/InputStep.tsx +++ b/packages/swap-widget/src/components/InputStep.tsx @@ -11,7 +11,8 @@ import { ReceiveAddressRow } from './ReceiveAddressRow' type InputStepProps = { displayValues: SwapDisplayValues onOpenTokenModal: (type: 'sell' | 'buy') => void - onSellAmountChange: (value: string) => void + onSellAmountChange: (value: string, sellAssetUsdPrice?: string) => void + onToggleSellFiat: (sellAssetUsdPrice?: string) => void onSwapTokens: () => void onSelectRate: (rate: TradeRate) => void onButtonClick: () => void @@ -23,6 +24,7 @@ export const InputStep = ({ displayValues, onOpenTokenModal, onSellAmountChange, + onToggleSellFiat, onSwapTokens, onSelectRate, onButtonClick, @@ -41,6 +43,7 @@ export const InputStep = ({ isSellBalanceLoading, isBuyBalanceLoading, sellUsdValue, + sellAssetUsdPrice, buyUsdValue, sellChainInfo, buyChainInfo, @@ -67,6 +70,8 @@ export const InputStep = ({ selectedRate, sellAmount, sellAmountBaseUnit, + isSellAmountFiat, + sellAmountFiat, isSellAssetEvm, isSellAssetUtxo, isSellAssetSolana, @@ -114,16 +119,17 @@ export const InputStep = ({
+ {isSellAmountFiat && $} { const raw = e.target.value.replace(/[^0-9.]/g, '') const parts = raw.split('.') const sanitized = parts.length > 2 ? parts[0] + '.' + parts.slice(1).join('') : raw - onSellAmountChange(sanitized) + onSellAmountChange(sanitized, sellAssetUsdPrice) }} /> + ) : ( + + )} {hasAnyWalletAddress && (isSellBalanceLoading ? ( diff --git a/packages/swap-widget/src/components/SwapWidget.css b/packages/swap-widget/src/components/SwapWidget.css index 4fbb4f77ea3..41bac1d2997 100644 --- a/packages/swap-widget/src/components/SwapWidget.css +++ b/packages/swap-widget/src/components/SwapWidget.css @@ -285,6 +285,31 @@ color: var(--ssw-text-muted); } +.ssw-usd-value-toggle { + background: none; + border: none; + padding: 0; + font: inherit; + font-size: 13px; + color: var(--ssw-text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ssw-usd-value-toggle:hover { + color: var(--ssw-text-secondary); +} + +.ssw-fiat-prefix { + font-size: 32px; + font-weight: 500; + color: var(--ssw-text-primary); + /* Counteract the row's 12px gap so the $ sits snug against the amount */ + margin-right: -8px; +} + .ssw-swap-divider { display: flex; justify-content: center; diff --git a/packages/swap-widget/src/components/SwapWidget.tsx b/packages/swap-widget/src/components/SwapWidget.tsx index d55519d545a..cd617d75db4 100644 --- a/packages/swap-widget/src/components/SwapWidget.tsx +++ b/packages/swap-widget/src/components/SwapWidget.tsx @@ -9,6 +9,7 @@ import type { SwapWalletContextValue } from '../contexts/SwapWalletContext' import { SwapWalletProvider } from '../contexts/SwapWalletContext' import { useBitcoinSigning } from '../hooks/useBitcoinSigning' import { useEvmSigning } from '../hooks/useEvmSigning' +import { useSellFiatSync } from '../hooks/useSellFiatSync' import { useSolanaSigning } from '../hooks/useSolanaSigning' import { useStatusPolling } from '../hooks/useStatusPolling' import { useSwapApproval } from '../hooks/useSwapApproval' @@ -73,6 +74,7 @@ const SwapWidgetContent = ({ handleSellAssetSelect, handleBuyAssetSelect, handleSellAmountChange, + handleToggleSellFiat, handleSelectRate, handleSlippageChange, handleButtonClick, @@ -82,6 +84,7 @@ const SwapWidgetContent = ({ useSwapApproval() useSwapExecution() useStatusPolling({ apiClient, onSwapSuccess, onSwapError, refetchSellBalance, refetchBuyBalance }) + useSellFiatSync(displayValues.sellAssetUsdPrice) const widgetStyle = useMemo(() => { if (!themeConfig) return undefined @@ -169,6 +172,7 @@ const SwapWidgetContent = ({ displayValues={displayValues} onOpenTokenModal={setTokenModalType} onSellAmountChange={handleSellAmountChange} + onToggleSellFiat={handleToggleSellFiat} onSwapTokens={handleSwapTokens} onSelectRate={handleSelectRate} onButtonClick={handleButtonClick} @@ -215,6 +219,7 @@ const SwapWidgetContent = ({ } allowedChainIds={(tokenModalType === 'buy' ? buyFilters : sellFilters).allowedChainIds} allowedAssetIds={(tokenModalType === 'buy' ? buyFilters : sellFilters).allowedAssetIds} + allowShapeshiftRedirect={allowShapeshiftRedirect} /> { @@ -69,6 +71,7 @@ export const TokenSelectModal = ({ disabledChainIds = [], allowedChainIds, allowedAssetIds, + allowShapeshiftRedirect, }: TokenSelectModalProps) => { useLockBodyScroll(isOpen) const [searchQuery, setSearchQuery] = useState('') @@ -111,7 +114,13 @@ export const TokenSelectModal = ({ }, [allowedAssetIds, allAssets]) const filteredChains = useMemo(() => { - let enabledChains = chains.filter(chain => !disabledChainIds.includes(chain.chainId)) + let enabledChains = chains.filter( + chain => isWidgetSupportedChainId(chain.chainId) && !disabledChainIds.includes(chain.chainId), + ) + + if (!allowShapeshiftRedirect) { + enabledChains = enabledChains.filter(chain => isWidgetExecutableChainId(chain.chainId)) + } if (allowedChainIds && allowedChainIds.length > 0) { enabledChains = enabledChains.filter(chain => allowedChainIds.includes(chain.chainId)) @@ -125,14 +134,27 @@ export const TokenSelectModal = ({ const lowerQuery = chainSearchQuery.toLowerCase() return enabledChains.filter(chain => chain.name.toLowerCase().includes(lowerQuery)) - }, [chains, chainSearchQuery, disabledChainIds, allowedChainIds, allowedAssetChainIds]) + }, [ + chains, + chainSearchQuery, + disabledChainIds, + allowedChainIds, + allowedAssetChainIds, + allowShapeshiftRedirect, + ]) const filteredAssets = useMemo(() => { let assets = allAssets.filter( asset => - !disabledAssetIds.includes(asset.assetId) && !disabledChainIds.includes(asset.chainId), + isWidgetSupportedChainId(asset.chainId) && + !disabledAssetIds.includes(asset.assetId) && + !disabledChainIds.includes(asset.chainId), ) + if (!allowShapeshiftRedirect) { + assets = assets.filter(asset => isWidgetExecutableChainId(asset.chainId)) + } + if (allowedAssetIds && allowedAssetIds.length > 0) { assets = assets.filter(asset => allowedAssetIds.includes(asset.assetId)) } @@ -173,6 +195,7 @@ export const TokenSelectModal = ({ disabledChainIds, allowedAssetIds, allowedChainIds, + allowShapeshiftRedirect, ]) const initialAssetPrecisions = useMemo(() => { diff --git a/packages/swap-widget/src/config/appkit.ts b/packages/swap-widget/src/config/appkit.ts index 7c6715430c9..7df859965b6 100644 --- a/packages/swap-widget/src/config/appkit.ts +++ b/packages/swap-widget/src/config/appkit.ts @@ -6,8 +6,13 @@ import { bitcoin, bsc, gnosis, + hyperEvm, + katana, mainnet, + megaeth, + monad, optimism, + plasma, polygon, solana, } from '@reown/appkit/networks' @@ -26,6 +31,11 @@ const EVM_NETWORKS: readonly AppKitNetwork[] = [ avalanche, bsc, gnosis, + monad, + megaeth, + hyperEvm, + plasma, + katana, ] const ALL_NETWORKS: readonly AppKitNetwork[] = [...EVM_NETWORKS, bitcoin, solana] diff --git a/packages/swap-widget/src/constants/chains.ts b/packages/swap-widget/src/constants/chains.ts index 1b67719e37f..a014065c682 100644 --- a/packages/swap-widget/src/constants/chains.ts +++ b/packages/swap-widget/src/constants/chains.ts @@ -10,9 +10,14 @@ import { dogecoin, ethereum, gnosis, + hyperevm, + katana, litecoin, mayachain, + megaeth, + monad, optimism, + plasma, polygon, solana, thorchain, @@ -29,6 +34,11 @@ const BASE_ASSETS_BY_CHAIN_ID: Record = { [avax.chainId]: avax, [bnbsmartchain.chainId]: bnbsmartchain, [gnosis.chainId]: gnosis, + [monad.chainId]: monad, + [megaeth.chainId]: megaeth, + [hyperevm.chainId]: hyperevm, + [plasma.chainId]: plasma, + [katana.chainId]: katana, [bitcoin.chainId]: bitcoin, [bitcoincash.chainId]: bitcoincash, [dogecoin.chainId]: dogecoin, diff --git a/packages/swap-widget/src/constants/viemChains.ts b/packages/swap-widget/src/constants/viemChains.ts index 8f0b8672e6a..71aaa5d84d6 100644 --- a/packages/swap-widget/src/constants/viemChains.ts +++ b/packages/swap-widget/src/constants/viemChains.ts @@ -1,5 +1,29 @@ import type { Chain, WalletClient } from 'viem' -import { arbitrum, avalanche, base, bsc, gnosis, mainnet, optimism, polygon } from 'viem/chains' +import { defineChain } from 'viem' +import { + arbitrum, + avalanche, + base, + bsc, + gnosis, + hyperEvm, + katana, + mainnet, + monad, + optimism, + plasma, + polygon, +} from 'viem/chains' + +const megaeth = defineChain({ + id: 4326, + name: 'MegaETH', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://mainnet.megaeth.com/rpc'] } }, + blockExplorers: { + default: { name: 'MegaETH Explorer', url: 'https://megaeth.blockscout.com' }, + }, +}) export const VIEM_CHAINS_BY_ID: Record = { [mainnet.id]: { @@ -34,6 +58,14 @@ export const VIEM_CHAINS_BY_ID: Record = { ...avalanche, rpcUrls: { default: { http: ['https://api.avalanche.shapeshift.com/api/v1/jsonrpc'] } }, }, + [monad.id]: { ...monad, rpcUrls: { default: { http: ['https://rpc.monad.xyz'] } } }, + [megaeth.id]: megaeth, + [hyperEvm.id]: { + ...hyperEvm, + rpcUrls: { default: { http: ['https://rpc.hyperliquid.xyz/evm'] } }, + }, + [plasma.id]: { ...plasma, rpcUrls: { default: { http: ['https://rpc.plasma.to'] } } }, + [katana.id]: { ...katana, rpcUrls: { default: { http: ['https://rpc.katana.network'] } } }, } export const addChainToWallet = async (client: WalletClient, chain: Chain): Promise => { diff --git a/packages/swap-widget/src/hooks/useBalances.ts b/packages/swap-widget/src/hooks/useBalances.ts index 17ba092c029..8249017a4c1 100644 --- a/packages/swap-widget/src/hooks/useBalances.ts +++ b/packages/swap-widget/src/hooks/useBalances.ts @@ -9,7 +9,13 @@ import { erc20Abi } from 'viem' import { useConfig } from 'wagmi' import type { AssetId } from '../types' -import { formatAmount, getChainType, UTXO_CHAIN_IDS } from '../types' +import { + formatAmount, + isWidgetExecutableEvmChainId, + isWidgetExecutableSolanaChainId, + isWidgetExecutableUtxoChainId, + UTXO_CHAIN_IDS, +} from '../types' const CONCURRENCY_LIMIT = 5 const DELAY_BETWEEN_BATCHES_MS = 50 @@ -58,7 +64,14 @@ const SOLANA_PUBLIC_RPC = 'https://api.mainnet-beta.solana.com' const parseAssetIdMultiChain = (assetId: AssetId): ParsedAsset => { try { const { chainNamespace, chainReference, assetNamespace, assetReference } = fromAssetId(assetId) - const chainType = getChainType(assetId.split('/')[0] as string) + const chainId = assetId.split('/')[0] as string + const chainType = isWidgetExecutableEvmChainId(chainId) + ? 'evm' + : isWidgetExecutableUtxoChainId(chainId) + ? 'utxo' + : isWidgetExecutableSolanaChainId(chainId) + ? 'solana' + : undefined if (chainType === 'evm' && chainNamespace === CHAIN_NAMESPACE.Evm) { const evmChainId = Number(chainReference) diff --git a/packages/swap-widget/src/hooks/useSellFiatSync.ts b/packages/swap-widget/src/hooks/useSellFiatSync.ts new file mode 100644 index 00000000000..2adc831de03 --- /dev/null +++ b/packages/swap-widget/src/hooks/useSellFiatSync.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react' + +import { SwapMachineCtx } from '../machines/SwapMachineContext' +import { computeSellFiatSyncAction } from '../utils/fiatConversion' + +// Fills in the crypto sell amount from the entered fiat when it's missing — e.g. after a +// sell-asset change clears it — and reverts to crypto entry when the asset has no price. In a +// hook, not the machine, because the conversion needs the market price. +export const useSellFiatSync = (sellAssetUsdPrice: string | undefined): void => { + const actorRef = SwapMachineCtx.useActorRef() + const isSellAmountFiat = SwapMachineCtx.useSelector(s => s.context.isSellAmountFiat) + const sellAmountFiat = SwapMachineCtx.useSelector(s => s.context.sellAmountFiat) + const sellAmountBaseUnit = SwapMachineCtx.useSelector(s => s.context.sellAmountBaseUnit) + const sellPrecision = SwapMachineCtx.useSelector(s => s.context.sellAsset.precision) + + useEffect(() => { + const action = computeSellFiatSyncAction({ + isSellAmountFiat, + sellAmountFiat, + sellAmountBaseUnit, + sellAssetUsdPrice, + sellPrecision, + }) + if (action) actorRef.send(action) + }, [ + actorRef, + isSellAmountFiat, + sellAmountFiat, + sellAmountBaseUnit, + sellAssetUsdPrice, + sellPrecision, + ]) +} diff --git a/packages/swap-widget/src/hooks/useSwapDisplayValues.ts b/packages/swap-widget/src/hooks/useSwapDisplayValues.ts index 663bfd8aca1..99497a2d8e0 100644 --- a/packages/swap-widget/src/hooks/useSwapDisplayValues.ts +++ b/packages/swap-widget/src/hooks/useSwapDisplayValues.ts @@ -1,4 +1,5 @@ import type { Asset as ShapeshiftAsset } from '@shapeshiftoss/types' +import { bn } from '@shapeshiftoss/utils' import { useMemo } from 'react' import type { ApiClient } from '../api/client' @@ -125,7 +126,11 @@ export const useSwapDisplayValues = ({ }, [sellAsset.assetId, buyAsset.assetId, sellChainNativeAsset]) const { data: marketData } = useMarketData(assetIdsForPrices) - const sellAssetUsdPrice = marketData?.[sellAsset.assetId]?.price + const sellAssetMarketDataPrice = marketData?.[sellAsset.assetId]?.price + const sellAssetUsdPrice = + sellAssetMarketDataPrice && bn(sellAssetMarketDataPrice).gt(0) + ? sellAssetMarketDataPrice + : undefined const buyAssetUsdPrice = marketData?.[buyAsset.assetId]?.price const nativeAssetUsdPrice = sellChainNativeAsset ? marketData?.[sellChainNativeAsset.assetId]?.price diff --git a/packages/swap-widget/src/hooks/useSwapHandlers.ts b/packages/swap-widget/src/hooks/useSwapHandlers.ts index 53e81ed69f5..a18923affec 100644 --- a/packages/swap-widget/src/hooks/useSwapHandlers.ts +++ b/packages/swap-widget/src/hooks/useSwapHandlers.ts @@ -5,6 +5,7 @@ import { useSwapWallet } from '../contexts/SwapWalletContext' import { SwapMachineCtx } from '../machines/SwapMachineContext' import type { Asset, TradeRate } from '../types' import { parseAmount } from '../types' +import { cryptoToFiat, fiatToCrypto } from '../utils/fiatConversion' import { buildShapeShiftTradeUrl } from '../utils/redirect' type UseSwapHandlersParams = { @@ -24,7 +25,7 @@ export const useSwapHandlers = ({ const snap = actorRef.getSnapshot() actorRef.send({ type: 'SET_SELL_ASSET', asset: snap.context.buyAsset }) actorRef.send({ type: 'SET_BUY_ASSET', asset: snap.context.sellAsset }) - actorRef.send({ type: 'SET_SELL_AMOUNT', amount: '', amountBaseUnit: undefined }) + actorRef.send({ type: 'SET_SELL_AMOUNT', amount: '', amountBaseUnit: undefined, fiatValue: '' }) }, [actorRef]) const handleSellAssetSelect = useCallback( @@ -42,10 +43,50 @@ export const useSwapHandlers = ({ ) const handleSellAmountChange = useCallback( - (value: string) => { + (value: string, sellAssetUsdPrice?: string) => { + const snap = actorRef.getSnapshot() + const { sellAsset, isSellAmountFiat } = snap.context + + if (isSellAmountFiat) { + const { amount, amountBaseUnit } = fiatToCrypto( + value, + sellAssetUsdPrice ?? '', + sellAsset.precision, + ) + + actorRef.send({ type: 'SET_SELL_AMOUNT', amount, amountBaseUnit, fiatValue: value }) + return + } + + const amountBaseUnit = value ? parseAmount(value, sellAsset.precision) : undefined + actorRef.send({ type: 'SET_SELL_AMOUNT', amount: value, amountBaseUnit, fiatValue: '' }) + }, + [actorRef], + ) + + const handleToggleSellFiat = useCallback( + (sellAssetUsdPrice?: string) => { + if (!sellAssetUsdPrice) return + const snap = actorRef.getSnapshot() - const baseUnit = value ? parseAmount(value, snap.context.sellAsset.precision) : undefined - actorRef.send({ type: 'SET_SELL_AMOUNT', amount: value, amountBaseUnit: baseUnit }) + const { sellAmount, sellAmountBaseUnit, sellAsset, isSellAmountFiat } = snap.context + + if (isSellAmountFiat) { + actorRef.send({ type: 'SET_SELL_FIAT_MODE', isFiat: false }) + return + } + + // Seed the fiat input for display but keep the exact crypto, so a round-trip toggle + // doesn't snap the amount to its 2-decimal fiat value. + const fiatValue = cryptoToFiat(sellAmountBaseUnit, sellAssetUsdPrice, sellAsset.precision) + + actorRef.send({ + type: 'SET_SELL_AMOUNT', + amount: sellAmount, + amountBaseUnit: sellAmountBaseUnit, + fiatValue, + }) + actorRef.send({ type: 'SET_SELL_FIAT_MODE', isFiat: true }) }, [actorRef], ) @@ -126,6 +167,7 @@ export const useSwapHandlers = ({ handleSellAssetSelect, handleBuyAssetSelect, handleSellAmountChange, + handleToggleSellFiat, handleSelectRate, handleSlippageChange, redirectToShapeShift, diff --git a/packages/swap-widget/src/index.ts b/packages/swap-widget/src/index.ts index 4f37eccdbd5..d1f91931e0a 100644 --- a/packages/swap-widget/src/index.ts +++ b/packages/swap-widget/src/index.ts @@ -25,6 +25,9 @@ export { UTXO_CHAIN_IDS, COSMOS_CHAIN_IDS, OTHER_CHAIN_IDS, + REDIRECT_ONLY_CHAIN_IDS, + isWidgetExecutableChainId, + isWidgetSupportedChainId, } from './types' export { diff --git a/packages/swap-widget/src/machines/__tests__/guards.test.ts b/packages/swap-widget/src/machines/__tests__/guards.test.ts index f230fa10d3c..35ddbec32fa 100644 --- a/packages/swap-widget/src/machines/__tests__/guards.test.ts +++ b/packages/swap-widget/src/machines/__tests__/guards.test.ts @@ -31,6 +31,8 @@ const createTestContext = (overrides?: Partial): SwapMachine }, sellAmount: '1.0', sellAmountBaseUnit: '1000000000000000000', + isSellAmountFiat: false, + sellAmountFiat: '', selectedRate: null, quote: null, txHash: null, diff --git a/packages/swap-widget/src/machines/__tests__/swapMachine.test.ts b/packages/swap-widget/src/machines/__tests__/swapMachine.test.ts index bef3fef837b..481a816a299 100644 --- a/packages/swap-widget/src/machines/__tests__/swapMachine.test.ts +++ b/packages/swap-widget/src/machines/__tests__/swapMachine.test.ts @@ -99,7 +99,12 @@ describe('swapMachine', () => { it('SET_SELL_ASSET recalculates sellAmountBaseUnit with new precision', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '0.001', amountBaseUnit: '1000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '0.001', + amountBaseUnit: '1000000000000000', + fiatValue: '', + }) expect(actor.getSnapshot().context.sellAmountBaseUnit).toBe('1000000000000000') actor.send({ type: 'SET_SELL_ASSET', asset: TEST_BTC }) @@ -131,7 +136,12 @@ describe('swapMachine', () => { it('SET_SELL_AMOUNT updates sellAmount and sellAmountBaseUnit', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '2.5', amountBaseUnit: '2500000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '2.5', + amountBaseUnit: '2500000000000000000', + fiatValue: '', + }) const ctx = actor.getSnapshot().context expect(ctx.sellAmount).toBe('2.5') expect(ctx.sellAmountBaseUnit).toBe('2500000000000000000') @@ -194,7 +204,12 @@ describe('swapMachine', () => { it('FETCH_QUOTE transitions to quoting when hasValidInput', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) expect(actor.getSnapshot().value).toBe('quoting') actor.stop() @@ -211,7 +226,7 @@ describe('swapMachine', () => { it('FETCH_QUOTE stays in input when sellAmountBaseUnit is "0"', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '0', amountBaseUnit: '0' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '0', amountBaseUnit: '0', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) expect(actor.getSnapshot().value).toBe('input') actor.stop() @@ -222,7 +237,12 @@ describe('swapMachine', () => { it('QUOTE_SUCCESS transitions to executing when no approval required', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) expect(actor.getSnapshot().value).toBe('executing') @@ -234,7 +254,7 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) expect(actor.getSnapshot().value).toBe('approval_needed') @@ -245,7 +265,12 @@ describe('swapMachine', () => { it('QUOTE_SUCCESS skips approval for native assets even when API says required', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) expect(actor.getSnapshot().value).toBe('executing') @@ -256,7 +281,12 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_BTC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '100000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '100000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) expect(actor.getSnapshot().value).toBe('executing') @@ -266,7 +296,12 @@ describe('swapMachine', () => { it('QUOTE_ERROR transitions to error with error message', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_ERROR', error: 'No quotes available' }) expect(actor.getSnapshot().value).toBe('error') @@ -280,7 +315,7 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) expect(actor.getSnapshot().value).toBe('approval_needed') @@ -293,7 +328,7 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) actor.send({ type: 'RESET' }) @@ -307,7 +342,7 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) actor.send({ type: 'APPROVE' }) @@ -321,7 +356,7 @@ describe('swapMachine', () => { const actor = createActor(swapMachine) actor.start() actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_WITH_APPROVAL }) actor.send({ type: 'APPROVE' }) @@ -336,7 +371,12 @@ describe('swapMachine', () => { it('EXECUTE_SUCCESS transitions to polling_status with txHash', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_SUCCESS', txHash: '0xTxHash' }) @@ -348,7 +388,12 @@ describe('swapMachine', () => { it('EXECUTE_ERROR transitions to error', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_ERROR', error: 'Transaction failed' }) @@ -362,7 +407,12 @@ describe('swapMachine', () => { it('STATUS_CONFIRMED transitions to complete', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_SUCCESS', txHash: '0xTxHash' }) @@ -374,7 +424,12 @@ describe('swapMachine', () => { it('STATUS_FAILED transitions to error', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_SUCCESS', txHash: '0xTxHash' }) @@ -389,7 +444,12 @@ describe('swapMachine', () => { it('RETRY transitions to executing and increments retryCount', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_ERROR', error: 'Gas too low' }) @@ -404,7 +464,12 @@ describe('swapMachine', () => { it('RETRY works up to 3 times then stays in error', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) @@ -433,7 +498,12 @@ describe('swapMachine', () => { it('RESET from error transitions to input and resets context', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_ERROR', error: 'Some error' }) actor.send({ type: 'RESET' }) @@ -451,7 +521,12 @@ describe('swapMachine', () => { it('RESET from complete transitions to input and resets context', () => { const actor = createActor(swapMachine) actor.start() - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) actor.send({ type: 'QUOTE_SUCCESS', quote: TEST_QUOTE_NO_APPROVAL }) actor.send({ type: 'EXECUTE_SUCCESS', txHash: '0xTxHash' }) @@ -495,7 +570,12 @@ describe('swapMachine', () => { actor.start() expect(actor.getSnapshot().value).toBe('input') - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000000000000000' }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }) actor.send({ type: 'FETCH_QUOTE' }) expect(actor.getSnapshot().value).toBe('quoting') @@ -521,7 +601,7 @@ describe('swapMachine', () => { expect(actor.getSnapshot().value).toBe('input') actor.send({ type: 'SET_SELL_ASSET', asset: TEST_USDC }) - actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000' }) + actor.send({ type: 'SET_SELL_AMOUNT', amount: '1', amountBaseUnit: '1000000', fiatValue: '' }) actor.send({ type: 'FETCH_QUOTE' }) expect(actor.getSnapshot().value).toBe('quoting') @@ -542,4 +622,66 @@ describe('swapMachine', () => { actor.stop() }) }) + + describe('fiat sell mode', () => { + it('initial context defaults to crypto mode', () => { + const ctx = createInitialContext() + expect(ctx.isSellAmountFiat).toBe(false) + expect(ctx.sellAmountFiat).toBe('') + }) + + it('SET_SELL_FIAT_MODE flips only the mode, leaving the amount untouched', () => { + const actor = createActor(swapMachine) + actor.start() + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '1', + amountBaseUnit: '1000000000000000000', + fiatValue: '100', + }) + actor.send({ type: 'SET_SELL_FIAT_MODE', isFiat: true }) + const { context } = actor.getSnapshot() + expect(context.isSellAmountFiat).toBe(true) + expect(context.sellAmount).toBe('1') + expect(context.sellAmountBaseUnit).toBe('1000000000000000000') + expect(context.sellAmountFiat).toBe('100') + actor.stop() + }) + + it('SET_SELL_AMOUNT stores the fiat string alongside the crypto amount', () => { + const actor = createActor(swapMachine) + actor.start() + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '0.03125', + amountBaseUnit: '31250000000000000', + fiatValue: '100', + }) + const { context } = actor.getSnapshot() + expect(context.sellAmount).toBe('0.03125') + expect(context.sellAmountBaseUnit).toBe('31250000000000000') + expect(context.sellAmountFiat).toBe('100') + actor.stop() + }) + + it('clears stale crypto on sell asset change while in fiat mode, keeping the fiat string', () => { + const actor = createActor(swapMachine) + actor.start() + actor.send({ type: 'SET_SELL_FIAT_MODE', isFiat: true }) + actor.send({ + type: 'SET_SELL_AMOUNT', + amount: '0.03125', + amountBaseUnit: '31250000000000000', + fiatValue: '100', + }) + actor.send({ type: 'SET_SELL_ASSET', asset: TEST_BTC }) + const { context } = actor.getSnapshot() + expect(context.sellAsset.symbol).toBe('BTC') + expect(context.isSellAmountFiat).toBe(true) + expect(context.sellAmountFiat).toBe('100') + expect(context.sellAmount).toBe('') + expect(context.sellAmountBaseUnit).toBeUndefined() + actor.stop() + }) + }) }) diff --git a/packages/swap-widget/src/machines/__tests__/types.test.ts b/packages/swap-widget/src/machines/__tests__/types.test.ts index b0c0808deb7..95862e6f321 100644 --- a/packages/swap-widget/src/machines/__tests__/types.test.ts +++ b/packages/swap-widget/src/machines/__tests__/types.test.ts @@ -22,6 +22,8 @@ describe('SwapMachine Types', () => { }, sellAmount: '1.0', sellAmountBaseUnit: '1000000000000000000', + isSellAmountFiat: false, + sellAmountFiat: '', selectedRate: null, quote: null, txHash: null, @@ -67,7 +69,13 @@ describe('SwapMachine Types', () => { precision: 6, }, }, - { type: 'SET_SELL_AMOUNT', amount: '1.0', amountBaseUnit: '1000000000000000000' }, + { + type: 'SET_SELL_AMOUNT', + amount: '1.0', + amountBaseUnit: '1000000000000000000', + fiatValue: '', + }, + { type: 'SET_SELL_FIAT_MODE', isFiat: true }, { type: 'SET_SLIPPAGE', slippage: '1.0' }, { type: 'SELECT_RATE', @@ -105,7 +113,7 @@ describe('SwapMachine Types', () => { }, ] - expect(events).toHaveLength(20) + expect(events).toHaveLength(21) const eventTypes = events.map(e => e.type) expect(eventTypes).toContain('SET_SELL_ASSET') expect(eventTypes).toContain('RESET') diff --git a/packages/swap-widget/src/machines/swapMachine.ts b/packages/swap-widget/src/machines/swapMachine.ts index 1df46596346..82398fe69c5 100644 --- a/packages/swap-widget/src/machines/swapMachine.ts +++ b/packages/swap-widget/src/machines/swapMachine.ts @@ -2,7 +2,13 @@ import { assign, setup } from 'xstate' import { DEFAULT_BUY_ASSET, DEFAULT_SELL_ASSET } from '../constants/defaults' import type { Asset, QuoteResponse, TradeRate } from '../types' -import { getChainType, parseAmount } from '../types' +import { + getChainType, + isWidgetExecutableEvmChainId, + isWidgetExecutableSolanaChainId, + isWidgetExecutableUtxoChainId, + parseAmount, +} from '../types' import * as guardFns from './guards' import type { SwapMachineContext, SwapMachineEvent } from './types' @@ -21,6 +27,8 @@ export const createInitialContext = (input?: { buyAsset, sellAmount: '', sellAmountBaseUnit: undefined, + isSellAmountFiat: false, + sellAmountFiat: '', selectedRate: null, quote: null, txHash: null, @@ -32,9 +40,9 @@ export const createInitialContext = (input?: { slippage: input?.slippage ?? '0.5', sendAddress: undefined, receiveAddress: undefined, - isSellAssetEvm: sellChainType === 'evm', - isSellAssetUtxo: sellChainType === 'utxo', - isSellAssetSolana: sellChainType === 'solana', + isSellAssetEvm: isWidgetExecutableEvmChainId(sellAsset.chainId), + isSellAssetUtxo: isWidgetExecutableUtxoChainId(sellAsset.chainId), + isSellAssetSolana: isWidgetExecutableSolanaChainId(sellAsset.chainId), isBuyAssetEvm: buyChainType === 'evm', } } @@ -67,15 +75,21 @@ export const swapMachine = setup({ assignSellAsset: assign(({ context, event }) => { const { asset } = event as { type: 'SET_SELL_ASSET'; asset: Asset } const chainType = getChainType(asset.chainId) + const cryptoFields = context.isSellAmountFiat + ? { sellAmount: '', sellAmountBaseUnit: undefined } + : { + sellAmount: context.sellAmount, + sellAmountBaseUnit: context.sellAmount + ? parseAmount(context.sellAmount, asset.precision) + : undefined, + } return { sellAsset: asset, - sellAmountBaseUnit: context.sellAmount - ? parseAmount(context.sellAmount, asset.precision) - : undefined, + ...cryptoFields, chainType, - isSellAssetEvm: chainType === 'evm', - isSellAssetUtxo: chainType === 'utxo', - isSellAssetSolana: chainType === 'solana', + isSellAssetEvm: isWidgetExecutableEvmChainId(asset.chainId), + isSellAssetUtxo: isWidgetExecutableUtxoChainId(asset.chainId), + isSellAssetSolana: isWidgetExecutableSolanaChainId(asset.chainId), selectedRate: null, quote: null, } @@ -91,16 +105,22 @@ export const swapMachine = setup({ } }), assignSellAmount: assign(({ event }) => { - const { amount, amountBaseUnit } = event as { + const { amount, amountBaseUnit, fiatValue } = event as { type: 'SET_SELL_AMOUNT' amount: string amountBaseUnit: string | undefined + fiatValue: string } return { sellAmount: amount, sellAmountBaseUnit: amountBaseUnit, + sellAmountFiat: fiatValue, } }), + assignSellFiatMode: assign(({ event }) => { + const { isFiat } = event as { type: 'SET_SELL_FIAT_MODE'; isFiat: boolean } + return { isSellAmountFiat: isFiat } + }), assignSlippage: assign(({ event }) => ({ slippage: (event as { type: 'SET_SLIPPAGE'; slippage: string }).slippage, })), @@ -184,6 +204,7 @@ export const swapMachine = setup({ SET_SELL_ASSET: { actions: 'assignSellAsset' }, SET_BUY_ASSET: { actions: 'assignBuyAsset' }, SET_SELL_AMOUNT: { actions: 'assignSellAmount' }, + SET_SELL_FIAT_MODE: { actions: 'assignSellFiatMode' }, SET_SLIPPAGE: { actions: 'assignSlippage' }, SELECT_RATE: { actions: 'assignSelectedRate' }, SET_SEND_ADDRESS: { actions: 'assignSendAddress' }, diff --git a/packages/swap-widget/src/machines/types.ts b/packages/swap-widget/src/machines/types.ts index 5738246fd9a..6f193864c86 100644 --- a/packages/swap-widget/src/machines/types.ts +++ b/packages/swap-widget/src/machines/types.ts @@ -7,6 +7,8 @@ export type SwapMachineContext = { buyAsset: Asset sellAmount: string sellAmountBaseUnit: string | undefined + isSellAmountFiat: boolean + sellAmountFiat: string selectedRate: TradeRate | null quote: QuoteResponse | null txHash: string | null @@ -27,7 +29,13 @@ export type SwapMachineContext = { export type SwapMachineEvent = | { type: 'SET_SELL_ASSET'; asset: Asset } | { type: 'SET_BUY_ASSET'; asset: Asset } - | { type: 'SET_SELL_AMOUNT'; amount: string; amountBaseUnit: string | undefined } + | { + type: 'SET_SELL_AMOUNT' + amount: string + amountBaseUnit: string | undefined + fiatValue: string + } + | { type: 'SET_SELL_FIAT_MODE'; isFiat: boolean } | { type: 'SET_SLIPPAGE'; slippage: string } | { type: 'SELECT_RATE'; rate: TradeRate } | { type: 'FETCH_QUOTE' } diff --git a/packages/swap-widget/src/types/index.ts b/packages/swap-widget/src/types/index.ts index 53ed85e07b3..6c371a32ec7 100644 --- a/packages/swap-widget/src/types/index.ts +++ b/packages/swap-widget/src/types/index.ts @@ -12,12 +12,23 @@ import { ethChainId, fromChainId, gnosisChainId, + hyperEvmChainId, + katanaChainId, ltcChainId, mayachainChainId, + megaethChainId, + monadChainId, + nearChainId, optimismChainId, + plasmaChainId, polygonChainId, solanaChainId, + starknetChainId, + suiChainId, thorchainChainId, + tonChainId, + tronChainId, + zecChainId, } from '@shapeshiftoss/caip' import type { TransactionData } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' @@ -229,6 +240,11 @@ export const EVM_CHAIN_IDS = { avalanche: avalancheChainId, bsc: bscChainId, gnosis: gnosisChainId, + monad: monadChainId, + megaEth: megaethChainId, + hyperEvm: hyperEvmChainId, + plasma: plasmaChainId, + katana: katanaChainId, } as const export const UTXO_CHAIN_IDS = { @@ -248,6 +264,15 @@ export const OTHER_CHAIN_IDS = { solana: solanaChainId, } as const +export const REDIRECT_ONLY_CHAIN_IDS = { + zcash: zecChainId, + tron: tronChainId, + sui: suiChainId, + ton: tonChainId, + near: nearChainId, + starknet: starknetChainId, +} as const + export const isEvmChainId = (chainId: string): boolean => { const { chainNamespace } = fromChainId(chainId as ChainId) return chainNamespace === CHAIN_NAMESPACE.Evm @@ -274,6 +299,34 @@ export const getChainType = (chainId: string): 'evm' | 'utxo' | 'cosmos' | 'sola } } +const EXECUTABLE_EVM_CHAIN_ID_SET: ReadonlySet = new Set(Object.values(EVM_CHAIN_IDS)) +const EXECUTABLE_UTXO_CHAIN_ID_SET: ReadonlySet = new Set(Object.values(UTXO_CHAIN_IDS)) + +export const isWidgetExecutableEvmChainId = (chainId: string): boolean => + EXECUTABLE_EVM_CHAIN_ID_SET.has(chainId) + +export const isWidgetExecutableUtxoChainId = (chainId: string): boolean => + EXECUTABLE_UTXO_CHAIN_ID_SET.has(chainId) + +export const isWidgetExecutableSolanaChainId = (chainId: string): boolean => + chainId === OTHER_CHAIN_IDS.solana + +export const isWidgetExecutableChainId = (chainId: string): boolean => + isWidgetExecutableEvmChainId(chainId) || + isWidgetExecutableUtxoChainId(chainId) || + isWidgetExecutableSolanaChainId(chainId) + +const SUPPORTED_CHAIN_ID_SET: ReadonlySet = new Set([ + ...Object.values(EVM_CHAIN_IDS), + ...Object.values(UTXO_CHAIN_IDS), + ...Object.values(COSMOS_CHAIN_IDS), + ...Object.values(OTHER_CHAIN_IDS), + ...Object.values(REDIRECT_ONLY_CHAIN_IDS), +]) + +export const isWidgetSupportedChainId = (chainId: string): boolean => + SUPPORTED_CHAIN_ID_SET.has(chainId) + export const formatAmount = (amount: string, decimals: number, maxDecimals?: number): string => { const effectiveMaxDecimals = maxDecimals ?? Math.min(decimals, 8) const result = BigAmount.fromBaseUnit({ value: amount, precision: decimals }).toFixed( diff --git a/packages/swap-widget/src/utils/__tests__/fiatConversion.test.ts b/packages/swap-widget/src/utils/__tests__/fiatConversion.test.ts new file mode 100644 index 00000000000..1b938daa7da --- /dev/null +++ b/packages/swap-widget/src/utils/__tests__/fiatConversion.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' + +import { computeSellFiatSyncAction, cryptoToFiat, fiatToCrypto } from '../fiatConversion' + +describe('fiatToCrypto', () => { + it('converts fiat to crypto amount and base unit (18 decimals)', () => { + // $100 at $3200/ETH => 0.03125 ETH + const result = fiatToCrypto('100', '3200', 18) + expect(result.amount).toBe('0.031250000000000000') + expect(result.amountBaseUnit).toBe('31250000000000000') + }) + + it('returns empty/undefined for empty fiat input', () => { + expect(fiatToCrypto('', '3200', 18)).toEqual({ amount: '', amountBaseUnit: undefined }) + }) + + it('returns empty/undefined for missing or zero price', () => { + expect(fiatToCrypto('100', '0', 18)).toEqual({ amount: '', amountBaseUnit: undefined }) + expect(fiatToCrypto('100', '', 18)).toEqual({ amount: '', amountBaseUnit: undefined }) + }) + + it('returns empty/undefined for non-numeric fiat', () => { + expect(fiatToCrypto('.', '3200', 18)).toEqual({ amount: '', amountBaseUnit: undefined }) + }) + + it('handles sub-cent fiat amounts', () => { + // $0.01 at $3200/ETH => 0.000003125 ETH => 3.125e12 base units + expect(fiatToCrypto('0.01', '3200', 18).amountBaseUnit).toBe('3125000000000') + }) + + it('handles low-precision assets (USDC, 6 decimals)', () => { + // $100 at $1/USDC => 100 USDC => 100_000000 base units + expect(fiatToCrypto('100', '1', 6)).toEqual({ + amount: '100.000000', + amountBaseUnit: '100000000', + }) + }) +}) + +describe('cryptoToFiat', () => { + it('converts base unit to a 2-decimal fiat string', () => { + expect(cryptoToFiat('31250000000000000', '3200', 18)).toBe('100.00') + }) + + it('returns empty for missing amount, zero amount, or missing price', () => { + expect(cryptoToFiat(undefined, '3200', 18)).toBe('') + expect(cryptoToFiat('0', '3200', 18)).toBe('') + expect(cryptoToFiat('31250000000000000', '0', 18)).toBe('') + }) +}) + +describe('computeSellFiatSyncAction', () => { + const base = { + isSellAmountFiat: true, + sellAmountFiat: '100', + sellAmountBaseUnit: undefined as string | undefined, + sellAssetUsdPrice: '3200' as string | undefined, + sellPrecision: 18, + } + + it('returns null when not in fiat mode', () => { + expect(computeSellFiatSyncAction({ ...base, isSellAmountFiat: false })).toBeNull() + }) + + it('falls back to crypto mode when price is missing in fiat mode', () => { + expect(computeSellFiatSyncAction({ ...base, sellAssetUsdPrice: undefined })).toEqual({ + type: 'SET_SELL_FIAT_MODE', + isFiat: false, + }) + }) + + it('returns null when there is no fiat amount entered', () => { + expect(computeSellFiatSyncAction({ ...base, sellAmountFiat: '' })).toBeNull() + }) + + it('reverts to crypto mode on a price-less asset even with no fiat entered', () => { + expect( + computeSellFiatSyncAction({ ...base, sellAmountFiat: '', sellAssetUsdPrice: undefined }), + ).toEqual({ type: 'SET_SELL_FIAT_MODE', isFiat: false }) + }) + + it('fills crypto from fiat when the crypto amount is missing', () => { + expect(computeSellFiatSyncAction(base)).toEqual({ + type: 'SET_SELL_AMOUNT', + amount: '0.031250000000000000', + amountBaseUnit: '31250000000000000', + fiatValue: '100', + }) + }) + + it('does not overwrite an existing crypto amount (no snapping to rounded fiat)', () => { + // crypto present but not the exact fiat/price round-trip — must be left untouched + expect(computeSellFiatSyncAction({ ...base, sellAmountBaseUnit: '999' })).toBeNull() + }) +}) diff --git a/packages/swap-widget/src/utils/fiatConversion.ts b/packages/swap-widget/src/utils/fiatConversion.ts new file mode 100644 index 00000000000..ae26c011bd5 --- /dev/null +++ b/packages/swap-widget/src/utils/fiatConversion.ts @@ -0,0 +1,61 @@ +import { BigAmount, bn } from '@shapeshiftoss/utils' + +import type { SwapMachineEvent } from '../machines/types' +import { parseAmount } from '../types' + +export const fiatToCrypto = ( + fiat: string, + price: string, + precision: number, +): { amount: string; amountBaseUnit: string | undefined } => { + const fiatBn = bn(fiat) + const priceBn = bn(price) + + if (!fiatBn.isFinite() || fiatBn.lte(0) || !priceBn.isFinite() || priceBn.lte(0)) { + return { amount: '', amountBaseUnit: undefined } + } + + const amount = fiatBn.div(priceBn).toFixed(precision) + + return { amount, amountBaseUnit: parseAmount(amount, precision) } +} + +export const cryptoToFiat = ( + amountBaseUnit: string | undefined, + price: string, + precision: number, +): string => { + const priceBn = bn(price) + + if (!amountBaseUnit || amountBaseUnit === '0' || !priceBn.isFinite() || priceBn.lte(0)) { + return '' + } + + const cryptoAmount = BigAmount.fromBaseUnit({ value: amountBaseUnit, precision }).toPrecision() + + return bn(cryptoAmount).times(priceBn).toFixed(2) +} + +export const computeSellFiatSyncAction = (params: { + isSellAmountFiat: boolean + sellAmountFiat: string + sellAmountBaseUnit: string | undefined + sellAssetUsdPrice: string | undefined + sellPrecision: number +}): SwapMachineEvent | null => { + const { isSellAmountFiat, sellAmountFiat, sellAmountBaseUnit, sellAssetUsdPrice, sellPrecision } = + params + + if (!isSellAmountFiat) return null + // Asset has no price: can't stay in fiat mode (no conversion, and the toggle is hidden), + // so revert to crypto entry regardless of whether an amount was entered. + if (!sellAssetUsdPrice) return { type: 'SET_SELL_FIAT_MODE', isFiat: false } + if (!sellAmountFiat) return null + // Only fill crypto when it's missing (e.g. cleared by a sell-asset change). Never overwrite an + // existing amount — that would snap an exact crypto value to its 2-decimal fiat round-trip. + if (sellAmountBaseUnit) return null + + const { amount, amountBaseUnit } = fiatToCrypto(sellAmountFiat, sellAssetUsdPrice, sellPrecision) + + return { type: 'SET_SELL_AMOUNT', amount, amountBaseUnit, fiatValue: sellAmountFiat } +} diff --git a/packages/swap-widget/src/utils/redirect.ts b/packages/swap-widget/src/utils/redirect.ts index ddb31d936b4..1004f73ffc5 100644 --- a/packages/swap-widget/src/utils/redirect.ts +++ b/packages/swap-widget/src/utils/redirect.ts @@ -1,5 +1,4 @@ -import type { Asset, AssetId } from '../types' -import { isEvmChainId } from '../types' +import type { AssetId } from '../types' const SHAPESHIFT_APP_URL = 'https://app.shapeshift.com' @@ -28,27 +27,3 @@ export const buildShapeShiftTradeUrl = (params: RedirectParams): string => { return `${SHAPESHIFT_APP_URL}/#/trade/${buyChainId}/${buyAssetSubId}/${sellChainId}/${sellAssetSubId}/${amount}${partner}` } - -export const redirectToShapeShift = (params: RedirectParams): void => { - window.open(buildShapeShiftTradeUrl(params), '_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/src/plugins/walletConnectToDapps/components/DAppInfo.tsx b/src/plugins/walletConnectToDapps/components/DAppInfo.tsx index dfa76dd19f3..84fe8abac40 100644 --- a/src/plugins/walletConnectToDapps/components/DAppInfo.tsx +++ b/src/plugins/walletConnectToDapps/components/DAppInfo.tsx @@ -18,7 +18,7 @@ export const DAppInfo: FC = ({ metadata }) => { return ( - {icon && } + {icon && } {name} {url} diff --git a/src/plugins/walletConnectToDapps/components/header/DappAvatar.tsx b/src/plugins/walletConnectToDapps/components/header/DappAvatar.tsx index b433ab28725..05a08e2e2b4 100644 --- a/src/plugins/walletConnectToDapps/components/header/DappAvatar.tsx +++ b/src/plugins/walletConnectToDapps/components/header/DappAvatar.tsx @@ -24,7 +24,7 @@ export const DappAvatar: React.FC = ({ const menuBg = useColorModeValue('gray.100', 'gray.700') return ( - + {connected && (