Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b956377
fix(swap-widget): keep externally payable chains when the redirect is…
kaladinlight Oct 15, 2026
379bce6
fix(swap-widget): drop routes the widget cannot run on unsigned sell …
kaladinlight Oct 15, 2026
49dab08
fix(swap-widget): make the route row static when there is one route
kaladinlight Oct 15, 2026
80954cf
fix(swap-widget): keep app-only routes listed while the redirect is on
kaladinlight Oct 15, 2026
5e23bad
fix(swap-widget): highlight the route in use when the picker opens
kaladinlight Oct 15, 2026
2eb3572
refactor(swap-widget): name the route row flag and swappable rules pl…
kaladinlight Oct 15, 2026
22a0384
fix(swap-widget): hover every route picker row the same way
kaladinlight Oct 15, 2026
de6d2c2
fix(swap-widget): keep a picked route current across rate polls
kaladinlight Oct 15, 2026
73c6ee8
fix(swap-widget): outline only the selected route in the picker
kaladinlight Oct 15, 2026
f6b3679
fix(swap-widget): resolve the active route once and honour it in the …
kaladinlight Oct 15, 2026
6eb768b
chore(swap-widget): drop comments that restate the code
kaladinlight Oct 15, 2026
b2692ae
fix(swap-widget): list routes in the picker in the order they arrive
kaladinlight Oct 16, 2026
b1a8f69
test(swap-widget): wrap the route fixture lines
kaladinlight Oct 16, 2026
7297181
docs(swap-widget): describe the route list, default route and pick be…
kaladinlight Oct 16, 2026
a6d9430
chore(swap-widget): tidy the route row and drop comments that restate…
kaladinlight Oct 16, 2026
88149e2
test(swap-widget): build route fixtures from SwapperName members
kaladinlight Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions packages/swap-widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ ShapeShift.
| `sellFilters` | `SwapWidgetFilters` | `{}` | Restrict which chains/assets are selectable for the **sell** side. See [Filtering](#filtering-chains-and-assets). |
| `buyFilters` | `SwapWidgetFilters` | `{}` | Restrict which chains/assets are selectable for the **buy** side. |
| `allowedSwapperNames` | `SwapperName[]` | all enabled | Limit quotes to specific swappers. See [Supported Swappers](#supported-swappers). |
| `allowShapeshiftRedirect`| `boolean` | `true` | When a swap isn't executable in-widget, redirect to app.shapeshift.com instead of hiding it. |
| `allowShapeshiftRedirect`| `boolean` | `true` | When a swap isn't executable in-widget, redirect to app.shapeshift.com instead of hiding it. Off, the selector keeps only chains the widget can sign for or pay externally. |
| `isBuyAssetLocked` | `boolean` | `false` | Prevent the user from changing the buy asset. |
| `defaultBuyAmountCryptoBaseUnit` | `string` | – | Drive the trade from the buy side: the user receives exactly this amount and the sell amount is derived. Restricts routing to exact-output swappers. See [Exact Output and Locked Destinations](#exact-output-and-locked-destinations). |
| `isBuyAmountLocked` | `boolean` | `false` | Prevent the user from changing the buy amount. When an amount is supplied it also locks the buy asset, since a base-unit amount is meaningless without the asset it counts. |
Expand Down Expand Up @@ -295,10 +295,11 @@ twice.

The app.shapeshift.com redirect carries neither the destination nor the buy amount, so following it
would drop whichever constraint you set. Locking the buy amount **or** the receive address
therefore disables it outright: `allowShapeshiftRedirect` has no effect, and assets on
non-executable chains drop out of the asset pickers rather than dead-ending. The pickers share that
filter, so Cosmos-SDK assets go from the **buy** side too, even though a swap into them works — only
the sell side needs a signature. The remaining redirect-only chains go with them.
therefore disables it outright: `allowShapeshiftRedirect` has no effect, and assets on chains the
widget can neither sign for nor pay through an [externally paid route](#externally-paid-swaps) drop
out of the asset pickers rather than dead-ending. The pickers share that filter, so Cosmos-SDK assets
go from the **buy** side too, even though a swap into them works — only the sell side needs a
signature. The remaining redirect-only chains go with them.

Note this is a wider condition than payment mode — locking either one is enough, because a single
dropped constraint can send funds somewhere you didn't intend.
Expand Down Expand Up @@ -358,7 +359,8 @@ wallet connected at all, and to serve chains it can't sign for.

There is nothing to configure. When the selected route comes from a deposit-address protocol and no
wallet is connected for the sell chain, the primary button becomes **Continue without a wallet**
instead of Connect Wallet.
instead of Connect Wallet. On a chain the widget can't sign for, the default route is the best
deposit-address one, so the button offers that path without a pick.

### What the user provides

Expand Down Expand Up @@ -602,6 +604,7 @@ import {
| `getChainType` | `(chainId: string) => "evm" \| "utxo" \| "cosmos" \| "solana" \| "other"` | Classify a chain by namespace. |
| `isWidgetSupportedChainId` | `(chainId: string) => boolean` | Whether the widget lists assets on this chain. |
| `isWidgetExecutableChainId` | `(chainId: string) => boolean` | Whether the widget can sign/execute swaps on this chain in-app. |
| `isWidgetSwappableChainId` | `(chainId: string) => boolean` | Whether the widget can complete a swap from this chain without the app, by signing or through a deposit address. |
| `getChainName` | `(chainId: ChainId) => string` | Display name for a chain. |
| `getChainIcon` | `(chainId: ChainId) => string \| undefined` | Icon URL for a chain. |
| `getChainColor` | `(chainId: ChainId) => string` | Brand color for a chain. |
Expand Down Expand Up @@ -692,7 +695,9 @@ Assets on the following chains appear in the selector. Swaps are **executed in-w
UTXO, and Solana assets (`isWidgetExecutableChainId` returns `true`). Cosmos-SDK and redirect-only
chains are selectable but route the user to [app.shapeshift.com](https://app.shapeshift.com) to
complete the swap (when `allowShapeshiftRedirect` is enabled), unless an
[externally paid route](#externally-paid-swaps) quotes them, which needs no signer.
[externally paid route](#externally-paid-swaps) quotes them, which needs no signer. With the redirect
disabled, only chains the widget can sign for or pay externally stay in the selector
(`isWidgetSwappableChainId`); the other redirect-only chains drop out.

| Chain | Chain ID | Type | Executable in-widget |
| ----------------- | ----------------------------------------- | ------ | -------------------- |
Expand Down Expand Up @@ -721,8 +726,12 @@ complete the swap (when `allowShapeshiftRedirect` is enabled), unless an

## Supported Swappers

The widget aggregates quotes across the protocols below and surfaces the best rate. Use
`allowedSwapperNames` to restrict which are used.
The widget aggregates quotes across the protocols below. The route list shows every route the
widget can take from the sell chain, best first, and starts on that best route; the user can pick
another. A pick is kept by protocol while rates refresh, and falls back to the default if that
protocol stops quoting. On a chain the widget can't sign for, routes that need the app stay listed
while `allowShapeshiftRedirect` is on and are dropped when it is off. Use `allowedSwapperNames` to
restrict which protocols are quoted.

- **NEAR Intents** (`SwapperName.NearIntents`) — deposit address
- **Chainflip** (`SwapperName.Chainflip`) — deposit address
Expand Down
5 changes: 2 additions & 3 deletions packages/swap-widget/src/components/InputStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,8 @@ export const InputStep = ({
sellAmountCrypto,
])

const selectedRate = context.selectedRate ?? displayValues.rates?.[0]
const isDepositFlowAvailable = shouldUseDepositFlow({
rate: selectedRate,
rate: displayValues.displayRate,
hasWalletForSellChain: !!walletSendAddress,
})

Expand Down Expand Up @@ -408,7 +407,7 @@ export const InputStep = ({
<div className='ssw-quotes'>
<QuoteSelector
rates={displayValues.rates ?? []}
selectedRate={context.selectedRate}
activeRate={displayValues.displayRate}
onSelectRate={onSelectRate}
buyAsset={context.buyAsset}
sellAsset={context.sellAsset}
Expand Down
9 changes: 8 additions & 1 deletion packages/swap-widget/src/components/QuoteSelector.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@
justify-content: center;
}

.ssw-quote-selector.ssw-loading:hover {
/* One route is information, not a choice */
.ssw-quote-selector.ssw-single {
cursor: default;
color: inherit;
}

.ssw-quote-selector.ssw-loading:hover,
.ssw-quote-selector.ssw-single:hover {
border-color: var(--ssw-border);
background: var(--ssw-bg-tertiary);
}
Expand Down
48 changes: 24 additions & 24 deletions packages/swap-widget/src/components/QuoteSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import './QuoteSelector.css'

import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'

import { getSwapperColor, getSwapperIcon } from '../constants/swappers'
import { formatUsdValue } from '../hooks/useMarketData'
Expand All @@ -11,7 +11,7 @@ import { QuotesModal } from './QuotesModal'

type QuoteSelectorProps = {
rates: TradeRate[]
selectedRate: TradeRate | null
activeRate: TradeRate | undefined
onSelectRate: (rate: TradeRate) => void
buyAsset: Asset
sellAsset: Asset
Expand All @@ -25,7 +25,7 @@ type QuoteSelectorProps = {

export const QuoteSelector = ({
rates,
selectedRate,
activeRate,
onSelectRate,
buyAsset,
sellAsset,
Expand All @@ -38,14 +38,12 @@ export const QuoteSelector = ({
}: QuoteSelectorProps) => {
const [isModalOpen, setIsModalOpen] = useState(false)

const bestRate = useMemo(() => rates[0], [rates])
const alternativeRatesCount = useMemo(() => Math.max(0, rates.length - 1), [rates])
const bestRate = rates[0]
const hasMultipleRates = rates.length > 1

const handleOpenModal = useCallback(() => {
if (rates.length > 0) {
setIsModalOpen(true)
}
}, [rates.length])
setIsModalOpen(true)
}, [])

const handleCloseModal = useCallback(() => {
setIsModalOpen(false)
Expand All @@ -69,16 +67,13 @@ export const QuoteSelector = ({
)
}

if (!bestRate) {
return null
}
if (!activeRate) return null

const displayRate = selectedRate ?? bestRate
const swapperIcon = getSwapperIcon(displayRate.swapperName)
const swapperColor = getSwapperColor(displayRate.swapperName)
const swapperIcon = getSwapperIcon(activeRate.swapperName)
const swapperColor = getSwapperColor(activeRate.swapperName)

const asset = isExactOutput ? sellAsset : buyAsset
const amountBaseUnit = getRateAmountBaseUnit(displayRate, isExactOutput)
const amountBaseUnit = getRateAmountBaseUnit(activeRate, isExactOutput)
const formattedAmount = formatAmount(amountBaseUnit, asset.precision)
const usdValue = formatUsdValue(
amountBaseUnit,
Expand All @@ -88,25 +83,30 @@ export const QuoteSelector = ({

return (
<>
<button className='ssw-quote-selector' onClick={handleOpenModal} type='button'>
<button
className={`ssw-quote-selector ${hasMultipleRates ? '' : 'ssw-single'}`}
onClick={handleOpenModal}
disabled={!hasMultipleRates}
type='button'
>
<div className='ssw-quote-left'>
<div className='ssw-quote-provider'>
{swapperIcon ? (
<img
src={swapperIcon}
alt={displayRate.swapperName}
alt={activeRate.swapperName}
className='ssw-quote-provider-icon'
/>
) : (
<div
className='ssw-quote-provider-icon-placeholder'
style={{ backgroundColor: swapperColor }}
>
{displayRate.swapperName.charAt(0)}
{activeRate.swapperName.charAt(0)}
</div>
)}
<span className='ssw-quote-provider-name'>{displayRate.swapperName}</span>
{displayRate === bestRate && <span className='ssw-quote-best-tag'>Best</span>}
<span className='ssw-quote-provider-name'>{activeRate.swapperName}</span>
{activeRate === bestRate && <span className='ssw-quote-best-tag'>Best</span>}
</div>
<span className='ssw-quote-usd'>{usdValue}</span>
</div>
Expand All @@ -116,9 +116,9 @@ export const QuoteSelector = ({
<span className='ssw-quote-amount'>{formattedAmount}</span>
<span className='ssw-quote-symbol'>{asset.symbol}</span>
</div>
{alternativeRatesCount > 0 && (
{hasMultipleRates && (
<span className='ssw-quote-more'>
+{alternativeRatesCount} more
+{rates.length - 1} more
<svg
width='12'
height='12'
Expand All @@ -138,7 +138,7 @@ export const QuoteSelector = ({
isOpen={isModalOpen}
onClose={handleCloseModal}
rates={rates}
selectedRate={selectedRate}
selectedRate={activeRate}
onSelectRate={handleSelectRate}
buyAsset={buyAsset}
sellAsset={sellAsset}
Expand Down
18 changes: 6 additions & 12 deletions packages/swap-widget/src/components/QuotesModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
padding: 14px 16px;
border: 1px solid transparent;
border-radius: var(--ssw-radius-md);
background: var(--ssw-bg-tertiary, #1a1a2e);
background-color: var(--ssw-bg-tertiary, #1a1a2e);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
Expand All @@ -133,21 +133,15 @@
}

.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: color-mix(in srgb, var(--ssw-accent, #3861fb) 6%, transparent);
}

.ssw-quote-row.ssw-best:hover {
background: color-mix(in srgb, var(--ssw-accent, #3861fb) 12%, transparent);
background-image: linear-gradient(
var(--ssw-bg-hover, rgba(255, 255, 255, 0.05)),
var(--ssw-bg-hover, rgba(255, 255, 255, 0.05))
);
}

.ssw-quote-row.ssw-selected {
border-color: var(--ssw-success, #00d395);
background: rgba(0, 211, 149, 0.06);
background-color: color-mix(in srgb, var(--ssw-success, #00d395) 6%, var(--ssw-bg-tertiary, #1a1a2e));
}

.ssw-quote-row-left {
Expand Down
20 changes: 8 additions & 12 deletions packages/swap-widget/src/components/QuotesModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import './QuotesModal.css'

import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect } from 'react'

import { getSwapperColor, getSwapperIcon } from '../constants/swappers'
import { formatUsdValue } from '../hooks/useMarketData'
import type { Asset, TradeRate } from '../types'
import { formatAmount } from '../types'
import { getRateAmountBaseUnit, getRateDiffPercent, sortRatesByValue } from '../utils/rateDisplay'
import { getRateAmountBaseUnit, getRateDiffPercent } from '../utils/rateDisplay'

const useLockBodyScroll = (isLocked: boolean) => {
useEffect(() => {
Expand All @@ -23,7 +23,7 @@ type QuotesModalProps = {
isOpen: boolean
onClose: () => void
rates: TradeRate[]
selectedRate: TradeRate | null
selectedRate: TradeRate | undefined
onSelectRate: (rate: TradeRate) => void
buyAsset: Asset
sellAsset: Asset
Expand Down Expand Up @@ -67,9 +67,7 @@ export const QuotesModal = ({
[onSelectRate, onClose],
)

const sortedRates = useMemo(() => sortRatesByValue(rates, isExactOutput), [rates, isExactOutput])

const bestRate = useMemo(() => sortedRates[0], [sortedRates])
const bestRate = rates[0]
const bestAmountBaseUnit = bestRate ? getRateAmountBaseUnit(bestRate, isExactOutput) : '0'

const asset = isExactOutput ? sellAsset : buyAsset
Expand Down Expand Up @@ -122,11 +120,11 @@ export const QuotesModal = ({
</div>

<div className='ssw-quotes-modal-list'>
{sortedRates.map((rate, index) => {
{rates.map((rate, index) => {
const amountBaseUnit = getRateAmountBaseUnit(rate, isExactOutput)
const estimatedTime = rate.estimatedExecutionTimeMs
const isBest = index === 0
const isSelected = selectedRate?.id === rate.id
const isSelected = selectedRate?.swapperName === rate.swapperName
const swapperIcon = getSwapperIcon(rate.swapperName)
const swapperColor = getSwapperColor(rate.swapperName)
const formattedAmount = formatAmount(amountBaseUnit, asset.precision)
Expand All @@ -139,10 +137,8 @@ export const QuotesModal = ({

return (
<button
key={rate.id}
className={`ssw-quote-row ${isSelected ? 'ssw-selected' : ''} ${
isBest ? 'ssw-best' : ''
}`}
key={rate.swapperName}
className={`ssw-quote-row ${isSelected ? 'ssw-selected' : ''}`}
onClick={() => handleSelectRate(rate)}
type='button'
>
Expand Down
1 change: 1 addition & 0 deletions packages/swap-widget/src/components/SwapWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const SwapWidgetContent = ({
const displayValues = useSwapDisplayValues({
apiClient,
allowedSwapperNames,
allowShapeshiftRedirect: canRedirectToShapeshift,
ratesRefetchInterval,
})
const { rates, sellAssetBalance, refetchSellBalance, refetchBuyBalance } = displayValues
Expand Down
6 changes: 3 additions & 3 deletions packages/swap-widget/src/components/TokenSelectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useAllMarketData } from '../hooks/useMarketData'
import { useSolanaSigning } from '../hooks/useSolanaSigning'
import { SwapMachineCtx } from '../machines/SwapMachineContext'
import type { Asset, AssetId, ChainId } from '../types'
import { isWidgetExecutableChainId, isWidgetSupportedChainId } from '../types'
import { isWidgetSupportedChainId, isWidgetSwappableChainId } from '../types'

const VISIBLE_BUFFER = 10

Expand Down Expand Up @@ -119,7 +119,7 @@ export const TokenSelectModal = ({
)

if (!allowShapeshiftRedirect) {
enabledChains = enabledChains.filter(chain => isWidgetExecutableChainId(chain.chainId))
enabledChains = enabledChains.filter(chain => isWidgetSwappableChainId(chain.chainId))
}

if (allowedChainIds && allowedChainIds.length > 0) {
Expand Down Expand Up @@ -152,7 +152,7 @@ export const TokenSelectModal = ({
)

if (!allowShapeshiftRedirect) {
assets = assets.filter(asset => isWidgetExecutableChainId(asset.chainId))
assets = assets.filter(asset => isWidgetSwappableChainId(asset.chainId))
}

if (allowedAssetIds && allowedAssetIds.length > 0) {
Expand Down
Loading
Loading