-
Notifications
You must be signed in to change notification settings - Fork 198
feat: swap widget #11630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
feat: swap widget #11630
Conversation
📝 WalkthroughWalkthroughThis PR introduces a complete cryptocurrency swap widget package ( Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant SwapWidget
participant TokenSelectModal
participant QuotesModal
participant AddressInputModal
participant API as API Client
participant WalletClient
User->>SwapWidget: Load widget with theme
User->>SwapWidget: Click select sell asset
SwapWidget->>TokenSelectModal: Open modal
TokenSelectModal->>TokenSelectModal: Fetch assets, chains, balances
User->>TokenSelectModal: Select asset (e.g., ETH)
TokenSelectModal->>SwapWidget: onSelect(asset)
User->>SwapWidget: Click select buy asset
SwapWidget->>TokenSelectModal: Open modal
User->>TokenSelectModal: Select asset (e.g., USDC)
TokenSelectModal->>SwapWidget: onSelect(asset)
User->>SwapWidget: Enter sell amount
SwapWidget->>API: getRates(sellAsset, buyAsset, amount)
API-->>SwapWidget: Return rates sorted by buy amount
SwapWidget->>QuoteSelector: Display best rate & alternatives
User->>SwapWidget: Click view all rates
SwapWidget->>QuotesModal: Open with rates
User->>QuotesModal: Select preferred rate
QuotesModal->>SwapWidget: onSelectRate(rate)
User->>SwapWidget: Click swap (non-EVM or address needed)
SwapWidget->>AddressInputModal: Open modal (validate by chainId)
User->>AddressInputModal: Enter receive address
AddressInputModal->>SwapWidget: onAddressChange(address)
User->>SwapWidget: Click execute swap
SwapWidget->>API: getQuote(params with address)
API-->>SwapWidget: Return quote with transaction data
SwapWidget->>WalletClient: Send transaction
WalletClient-->>SwapWidget: Transaction hash
SwapWidget->>SwapWidget: Poll tx status, update UI
SwapWidget->>User: Display success/error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Fix all ESLint and Prettier errors across all files - Replace custom ThrottledQueue with p-queue library - Add proper ARIA roles and accessibility to modals - Remove non-null assertions in favor of proper guards - Add lint/type-check scripts to package.json - Update README with new props (allowedChainIds, defaultReceiveAddress, enableWalletConnection, walletConnectProjectId) - Fix demo app to use ShapeShift WalletConnect project ID - Add min-height to token modal for better UX
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 13
🤖 Fix all issues with AI agents
In @packages/swap-widget/package.json:
- Around line 17-27: Remove "react" and "react-dom" from the "dependencies"
object in package.json and ensure they remain declared only in
"peerDependencies" (and optionally in "devDependencies" for build/test tooling);
update the package.json by deleting the entries for "react" and "react-dom"
under "dependencies" so the project will not install its own React copy and will
rely solely on the host app's peer versions.
- Around line 25-26: The swap-widget package.json declares mismatched dependency
constraints for viem and wagmi compared to the monorepo; update the "viem" and
"wagmi" entries in swap-widget's package.json to align with the root and other
packages (use viem ^2.40.3 to match others and set wagmi to the same caret range
as root, e.g., ^2.9.2), or if a different version is required, add a clear
comment in swap-widget's package.json and a short rationale in the package
README explaining why it needs a unique wagmi version; ensure the change
references the swap-widget package.json dependencies block so monorepo
resolution remains consistent.
In @packages/swap-widget/README.md:
- Line 79: The table cell documenting `walletConnectProjectId` contains a bare
URL; update the README row so the URL is either wrapped in angle brackets like
<https://cloud.walletconnect.com> or formatted as a Markdown link such as
https://cloud.walletconnect.com (display text)[https://cloud.walletconnect.com]
to eliminate the bare-URL lint warning and preserve link behavior in the
`walletConnectProjectId` description.
In @packages/swap-widget/src/api/client.ts:
- Around line 36-40: Add timeout handling to the fetch by creating an
AbortController, attaching its signal to the existing fetchOptions before
calling fetch(url.toString(), fetchOptions), and starting a setTimeout that
calls controller.abort() after the configured timeout (e.g., default or
provided). Ensure you clear the timeout on success/failure, handle the abort
error (throw a clear timeout-specific Error or rethrow), and keep the existing
response.ok check and return response.json() as Promise<T>; update usage of
fetchOptions and url and reference the AbortController signal when initiating
fetch.
In @packages/swap-widget/src/components/AddressInputModal.css:
- Around line 75-81: The .ssw-address-error rule uses a hardcoded color
(#ef4444); update this to the CSS variable var(--ssw-error) so the error text
color is consistent with the theme by replacing the color property value in the
.ssw-address-error selector with var(--ssw-error).
- Around line 39-41: Replace the hardcoded error color in the
.ssw-address-input-wrapper.ssw-invalid rule by using the theme CSS variable to
ensure consistency: change the value from #ef4444 to var(--ssw-error) in the
.ssw-address-input-wrapper.ssw-invalid selector so it uses the --ssw-error value
defined in SwapWidget.css.
In @packages/swap-widget/src/components/AddressInputModal.tsx:
- Around line 103-110: The backdrop div's onKeyDown won't fire because a plain
div is not focusable; make the backdrop focusable by adding a tabIndex (e.g.,
tabIndex={-1}) to the same element that uses handleBackdropClick, and ensure it
receives focus when opened (call focus on that element when isOpen becomes
true); additionally add a document-level keydown listener in a useEffect that
watches isOpen and onClose to reliably call onClose() when Escape is pressed,
and clean up the listener on unmount.
In @packages/swap-widget/src/components/SwapWidget.tsx:
- Around line 246-253: The approval sendTransaction call currently doesn't wait
for mining; capture its returned transaction object (from client.sendTransaction
when sending to sellAssetAddress with approvalData and account walletAddress)
into a variable (e.g., approvalTx) and await its confirmation before proceeding
to create/send the swap transaction—use the client-provided confirmation method
(approvalTx.wait() or client.waitForTransaction(approvalTx.hash)) and
handle/errors/timeouts accordingly so the swap only runs after the approval is
mined.
- Around line 220-225: The hardcoded chain object in SwapWidget.tsx sets
nativeCurrency to ETH which is wrong for non-Ethereum chains; update the
creation of the chain object (the variable named "chain" built using
requiredChainId) to populate nativeCurrency dynamically from the chain metadata
or the sell asset's chain info (e.g., read nativeCurrency/symbol/decimals from
your chain registry or sellAsset.chain data), falling back to a sensible default
if missing, and ensure rpcUrls is preserved; locate where "chain" is constructed
and replace the hardcoded nativeCurrency with the derived values.
In @packages/swap-widget/src/hooks/useMarketData.ts:
- Around line 66-72: The proxy availability check using the fetch to
`${COINGECKO_PROXY_URL}/coins/markets?...` (the testResponse logic in
useMarketData) needs a timeout to avoid hanging; create an AbortController, pass
controller.signal into the fetch call, set a short timeout (e.g., 2–5s) to call
controller.abort(), and ensure the catch branch treats abort/errors as an
unavailable proxy (so baseUrl = COINGECKO_DIRECT_URL when the fetch is aborted
or fails). Also clear the timeout after fetch completes to avoid leaks and keep
the existing logic that checks testResponse?.ok to decide the baseUrl.
In @packages/swap-widget/src/hooks/useSwapQuote.ts:
- Around line 29-38: The queryKey used in useSwapQuote is missing the
slippageTolerancePercentageDecimal so React Query returns stale quotes when
slippage changes; update the queryKey array in the useQuery call inside
useSwapQuote to include slippageTolerancePercentageDecimal (the same param you
pass to the API) so the cache is keyed by slippage and a new quote is fetched
whenever it changes.
In @packages/swap-widget/src/utils/addressValidation.ts:
- Around line 204-206: The 'cosmos' switch case declares const prefix without
block scope causing lexical scoping issues; wrap the case body in braces so the
prefix is block-scoped — i.e., change the case 'cosmos' branch to: case
'cosmos': { const prefix = getCosmosPrefix(chainId); return prefix ?
`${prefix}1...` : 'Enter address'; } — ensuring the const is scoped to that
case.
- Around line 161-169: The 'cosmos' switch case in validateAddress (or the
function containing this switch) declares const expectedPrefix which can leak to
other cases; wrap the entire 'case "cosmos":' body in braces { ... } so
expectedPrefix (and any other const/let declarations like trimmedAddress usage)
are block-scoped, keeping the call sites getCosmosPrefix and
isValidCosmosAddress unchanged and returning the same { valid: false, error: ...
} on failure.
🧹 Nitpick comments (25)
packages/swap-widget/src/vite-env.d.ts (1)
3-6: Consider extending EthereumProvider for broader event support.The
onmethod signature only supports callbacks withstring[](accounts), but EIP-1193 providers emit various events with different payload types (e.g.,chainChangedreturns a hex string,disconnectreturns an error object). If the widget needs to handle other events, this type will be insufficient.♻️ Suggested improvement for broader compatibility
interface EthereumProvider { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> - on: (event: string, callback: (accounts: string[]) => void) => void + on: (event: string, callback: (...args: unknown[]) => void) => void + removeListener?: (event: string, callback: (...args: unknown[]) => void) => void }packages/swap-widget/src/components/AddressInputModal.css (1)
130-134: Hardcodedcolor: whitemay not work well in all theme contexts.Consider using a CSS variable for the button text color to maintain theme consistency, especially if a custom accent color is configured that doesn't contrast well with white.
packages/swap-widget/src/components/SwapWidget.css (1)
429-437: Consider using CSS variables for status background colors.The hardcoded
rgba()values for success and error backgrounds could use CSS variables with opacity applied, maintaining consistency with the theming approach used elsewhere.♻️ Example using CSS variables with color-mix
.ssw-tx-status-success { border-color: var(--ssw-success); - background: rgba(0, 211, 149, 0.1); + background: color-mix(in srgb, var(--ssw-success) 10%, transparent); } .ssw-tx-status-error { border-color: var(--ssw-error); - background: rgba(244, 67, 54, 0.1); + background: color-mix(in srgb, var(--ssw-error) 10%, transparent); }packages/swap-widget/vite.config.ts (1)
19-38: Consider externalizing wagmi/viem for library builds.The library build correctly externalizes
reactandreact-dom, butwagmiandviemare listed as dependencies rather than peer dependencies in package.json. If consumers already use wagmi/viem, this could lead to duplicate instances and version conflicts. Consider either:
- Externalizing
wagmiandviemin rollupOptions- Moving them to peerDependencies in package.json
Also, explicitly specifying output formats (e.g.,
formats: ['es', 'cjs']) would make the build output clearer.Suggested externals expansion
rollupOptions: { - external: ["react", "react-dom"], + external: ["react", "react-dom", "wagmi", "viem"], output: { globals: { react: "React", "react-dom": "ReactDOM", + wagmi: "wagmi", + viem: "viem", }, }, },packages/swap-widget/Dockerfile (1)
5-8: Review--legacy-peer-depsusage and consider adding a non-root user.Using
--legacy-peer-depsmasks peer dependency conflicts that could cause runtime issues. Consider resolving the underlying peer dependency mismatches if possible.Additionally, for better container security, consider running as a non-root user:
Suggested security improvement
FROM node:20-slim +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 widget + RUN npm install -g serve WORKDIR /app COPY --from=builder /app/dist ./dist +USER widget + EXPOSE 3000 CMD ["serve", "-s", "dist", "-l", "3000"]packages/swap-widget/src/components/WalletProvider.tsx (1)
18-31: Consider typingwalletClientmore explicitly.The
unknowntype forwalletClientin the render prop pattern loses type safety. While this works, consumers won't get IntelliSense or type checking.♻️ Suggested improvement
+import type { WalletClient } from 'viem' + type InternalWalletProviderProps = { projectId: string - children: (walletClient: unknown) => ReactNode + children: (walletClient: WalletClient | undefined) => ReactNode themeMode: ThemeMode } const InternalWalletContent = ({ children, }: { - children: (walletClient: unknown) => ReactNode + children: (walletClient: WalletClient | undefined) => ReactNode }) => {packages/swap-widget/src/demo/App.css (1)
343-346: Consider preserving focus indication for accessibility.Removing
outline: noneon focus can harm keyboard navigation. The border-color change may be subtle for some users.♻️ Alternative that preserves accessibility
.demo-color-text:focus { - outline: none; + outline: 2px solid var(--demo-accent); + outline-offset: 2px; border-color: var(--demo-accent); }Or keep the current approach if the border change provides sufficient visual feedback for this demo context.
packages/swap-widget/src/components/QuotesModal.css (1)
14-45: Consider adding reduced motion support for accessibility.The animations look clean. For users who prefer reduced motion, consider adding a media query to disable or minimize these animations.
♿ Optional: Add prefers-reduced-motion support
@media (prefers-reduced-motion: reduce) { .ssw-quotes-modal-backdrop, .ssw-quotes-modal { animation: none; } }packages/swap-widget/src/hooks/useSwapRates.ts (1)
34-38: Consider BigInt for precise comparison of large crypto amounts.Using
parseFloatfor sorting works for most cases, but crypto base unit amounts can exceed JavaScript's safe integer limit (2^53). For very large amounts, precision could be lost.♻️ Optional: Use BigInt for precise sorting
.sort((a, b) => { - const aAmount = parseFloat(a.buyAmountCryptoBaseUnit) - const bAmount = parseFloat(b.buyAmountCryptoBaseUnit) - return bAmount - aAmount + const aAmount = BigInt(a.buyAmountCryptoBaseUnit) + const bAmount = BigInt(b.buyAmountCryptoBaseUnit) + return bAmount > aAmount ? 1 : bAmount < aAmount ? -1 : 0 })packages/swap-widget/src/constants/swappers.ts (1)
28-41: SWAPPER_COLORS could useRecordinstead ofPartial<Record>.All SwapperName values have color entries defined. Using
Record<SwapperName, string>would provide compile-time enforcement that all swappers have colors, matching the pattern used forSWAPPER_ICONS.♻️ Optional refinement
-export const SWAPPER_COLORS: Partial<Record<SwapperName, string>> = { +export const SWAPPER_COLORS: Record<SwapperName, string> = {packages/swap-widget/src/components/AddressInputModal.tsx (1)
113-114: Hardcoded English strings should use translation keys.Per coding guidelines, all user-facing text should use translation keys. Strings like "Receive Address", "Enter {chainName} address", "Use connected wallet", "Reset to Wallet", and "Confirm" are hardcoded.
This can be addressed in a follow-up PR to avoid expanding scope.
Also applies to: 131-132, 203-203, 213-213, 221-221
packages/swap-widget/src/constants/chains.ts (1)
89-95: Use TrustWallet icon source for consistency with other chains.Dogecoin currently uses CoinGecko while all other chains use TrustWallet assets. While the CoinGecko URL works, standardizing on TrustWallet maintains consistency across the metadata.
'bip122:00000000001a91e3dace36e2be3bf030': { chainId: 'bip122:00000000001a91e3dace36e2be3bf030', name: 'Dogecoin', shortName: 'DOGE', color: '#FFC107', - icon: 'https://assets.coingecko.com/coins/images/5/large/dogecoin.png', + icon: 'https://rawcdn.githack.com/trustwallet/assets/b7a5f12d893fcf58e0eb1dd64478f076857b720b/blockchains/doge/info/logo.png', },packages/swap-widget/src/components/SettingsModal.css (1)
119-133: Consider using CSS variables for warning/error colors for theming consistency.The warning and error states use hardcoded colors (
#ffc107,#f44336) while the rest of the file uses CSS custom properties (--ssw-*). This could cause visual inconsistencies when the widget is embedded in different theme contexts.♻️ Suggested refactor
.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; + background: var(--ssw-warning-bg, rgba(255, 193, 7, 0.1)); + color: var(--ssw-warning-text, #ffc107); font-size: 13px; line-height: 1.4; } .ssw-slippage-warning.ssw-error { - background: rgba(244, 67, 54, 0.1); - color: #f44336; + background: var(--ssw-error-bg, rgba(244, 67, 54, 0.1)); + color: var(--ssw-error-text, #f44336); }packages/swap-widget/src/components/SettingsModal.tsx (2)
81-81: Extract inlineonKeyDownhandler to a memoized callback.The inline arrow function creates a new reference on each render. Per coding guidelines, callbacks should be wrapped in
useCallback.♻️ Suggested refactor
+ const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + }, + [onClose], + ) + return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions <div className='ssw-modal-backdrop' onClick={handleBackdropClick} - onKeyDown={e => e.key === 'Escape' && onClose()} + onKeyDown={handleKeyDown} role='dialog'
88-89: Hardcoded UI strings may limit internationalization.The component contains hardcoded English strings ("Settings", "Slippage Tolerance", warning messages). If i18n support is planned for the widget, consider introducing a translation mechanism or accepting string props for customization.
Also applies to: 108-108, 168-171
packages/swap-widget/src/components/QuoteSelector.tsx (2)
96-96: Object reference comparison may be unreliable.
displayRate === bestRateuses reference equality. IfselectedRateis reconstructed (e.g., from a new API response with the same data), this comparison could incorrectly show "Best" on a non-best rate, or fail to show it on the actual best rate.Consider comparing by a stable identifier:
♻️ Suggested refactor
- {displayRate === bestRate && <span className='ssw-quote-best-tag'>Best</span>} + {displayRate.id === bestRate.id && <span className='ssw-quote-best-tag'>Best</span>}
47-52: Redundant callback wrapper.
handleSelectRatesimply delegates toonSelectRatewithout any transformation. Consider passingonSelectRatedirectly to simplify.♻️ Suggested refactor
- const handleSelectRate = useCallback( - (rate: TradeRate) => { - onSelectRate(rate) - }, - [onSelectRate], - ) - ... <QuotesModal isOpen={isModalOpen} onClose={handleCloseModal} rates={rates} selectedRate={selectedRate} - onSelectRate={handleSelectRate} + onSelectRate={onSelectRate} buyAsset={buyAsset}packages/swap-widget/src/components/SwapWidget.tsx (1)
243-243: Approval uses exact amount instead of max allowance.Approving only
BigInt(sellAmountBaseUnit)means users will need to re-approve for every swap. Consider usingMaxUint256for unlimited approval, or provide a toggle for users to choose between exact and unlimited approval.♻️ Suggested fix for unlimited approval
+const MAX_UINT256 = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') + // In the approval section: - args: [quoteResponse.approval.spender as `0x${string}`, BigInt(sellAmountBaseUnit)], + args: [quoteResponse.approval.spender as `0x${string}`, MAX_UINT256],packages/swap-widget/src/demo/App.tsx (1)
130-134: Clipboard API error handling is minimal.The
navigator.clipboard.writeTextcall uses.then()but doesn't handle potential failures (e.g., when clipboard access is denied). Consider adding error handling.♻️ Add error handling for clipboard
- navigator.clipboard.writeText(code).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) + navigator.clipboard.writeText(code) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + console.warn('Failed to copy to clipboard') + })packages/swap-widget/src/hooks/useMarketData.ts (1)
119-135: Filter logic is not memoized and recomputes on every render.The
filteredDatais computed using an IIFE that runs on every render. SinceuseMarketDatamay be called frequently, this could cause unnecessary recomputations.♻️ Memoize the filtered data
export const useMarketData = (assetIds: AssetId[]) => { const { data: allMarketData, ...rest } = useAllMarketData() - const filteredData = (() => { + const filteredData = useMemo(() => { if (!allMarketData) return {} const result: MarketDataById = {} for (const assetId of assetIds) { if (allMarketData[assetId]) { result[assetId] = allMarketData[assetId] } } return result - })() + }, [allMarketData, assetIds]) return { data: filteredData, ...rest } }Note: This requires importing
useMemofrom React.packages/swap-widget/src/components/TokenSelectModal.tsx (1)
14-23: useLockBodyScroll hook is duplicated across modal components.This hook is also defined in
QuotesModal.tsx. Consider extracting it to a shared hooks file to avoid duplication.This is a minor DRY improvement that can be deferred to a follow-up PR. Based on learnings, NeOMakinG prefers keeping PRs focused.
packages/swap-widget/src/api/client.ts (1)
37-39: Error response body is not included in error message.When the API returns an error, only the status code and status text are included. The response body often contains useful error details that would help with debugging.
♻️ Include response body in error
const response = await fetch(url.toString(), fetchOptions) if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) + const errorBody = await response.text().catch(() => '') + throw new Error(`API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`) }packages/swap-widget/src/hooks/useAssets.ts (2)
80-102: Chains computation is not memoized.The
chainsvariable is computed inside an IIFE that runs on every render of components usinguseChains. Since this involves iterating over all assets and building a Map, it should be memoized.♻️ Memoize chains computation
+import { useMemo } from 'react' export const useChains = () => { const { data: assets, ...rest } = useAssets() - const chains = (() => { + const chains = useMemo(() => { if (!assets.length) return [] const chainMap = new Map<ChainId, ChainInfo>() // ... rest of the computation return Array.from(chainMap.values()).sort((a, b) => a.name.localeCompare(b.name)) - })() + }, [assets]) return { data: chains, ...rest } }
148-173: Search results computation is not memoized.Similar to
useChains, thesearchResultsinuseAssetSearchis computed in an IIFE without memoization. This could cause performance issues with frequent re-renders.♻️ Memoize search results
export const useAssetSearch = (query: string, chainId?: ChainId) => { const { data: assets, ...rest } = useAssets() - const searchResults = (() => { + const searchResults = useMemo(() => { let filtered = chainId ? assets.filter(a => a.chainId === chainId) : assets // ... rest of the computation return matched - })() + }, [assets, query, chainId]) return { data: searchResults, ...rest } }packages/swap-widget/src/types/index.ts (1)
1-2: Consider using branded/nominal types for domain identifiers.Per coding guidelines,
Nominaltypes are preferred for domain identifiers likeChainIdandAssetIdto prevent accidental misuse. However, since this is a standalone package with its own dependency footprint, plain string aliases may be acceptable for simplicity.Example pattern if stronger typing is desired:
type Brand<K, T> = K & { __brand: T } export type ChainId = Brand<string, 'ChainId'> export type AssetId = Brand<string, 'AssetId'>
| "dependencies": { | ||
| "@rainbow-me/rainbowkit": "^2.2.3", | ||
| "@shapeshiftoss/caip": "^8.16.5", | ||
| "@tanstack/react-query": "^5.60.0", | ||
| "p-queue": "^8.0.1", | ||
| "react": "^18.2.0", | ||
| "react-dom": "^18.2.0", | ||
| "react-virtuoso": "^4.18.1", | ||
| "viem": "^2.21.0", | ||
| "wagmi": "^2.14.0" | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React should only be in peerDependencies, not dependencies.
Having react and react-dom in both dependencies and peerDependencies can cause duplicate React instances at runtime, leading to hooks failures and other issues. Remove them from dependencies and keep only the peer dependency declaration.
Suggested fix
"dependencies": {
"@rainbow-me/rainbowkit": "^2.2.3",
"@shapeshiftoss/caip": "^8.16.5",
"@tanstack/react-query": "^5.60.0",
"p-queue": "^8.0.1",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
"react-virtuoso": "^4.18.1",
"viem": "^2.21.0",
"wagmi": "^2.14.0"
},🤖 Prompt for AI Agents
In @packages/swap-widget/package.json around lines 17 - 27, Remove "react" and
"react-dom" from the "dependencies" object in package.json and ensure they
remain declared only in "peerDependencies" (and optionally in "devDependencies"
for build/test tooling); update the package.json by deleting the entries for
"react" and "react-dom" under "dependencies" so the project will not install its
own React copy and will rely solely on the host app's peer versions.
| "viem": "^2.21.0", | ||
| "wagmi": "^2.14.0" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find all viem and wagmi version declarations in package.json files
rg -n '"(viem|wagmi)":\s*"[^"]*"' --type jsonRepository: shapeshift/web
Length of output: 548
Viem and wagmi versions diverge from root and other packages; wagmi mismatch is particularly problematic.
The swap-widget declares viem: ^2.21.0 and wagmi: ^2.14.0, while:
- Root package.json has
viem: 2.40.3andwagmi: ^2.9.2 - Other packages use
viem: ^2.40.3
For viem, swap-widget's ^2.21.0 is compatible with the root's 2.40.3, but it's less strict than most other packages which pin to ^2.40.3.
For wagmi, the constraint is problematic: swap-widget requires ^2.14.0 (minimum 2.14.0) while root allows ^2.9.2. In a monorepo dependency resolution, this could cause version conflicts. Additionally, swap-widget is the only package declaring wagmi, making this divergence stand out.
Align the versions or document why swap-widget intentionally uses older constraints.
🤖 Prompt for AI Agents
In @packages/swap-widget/package.json around lines 25 - 26, The swap-widget
package.json declares mismatched dependency constraints for viem and wagmi
compared to the monorepo; update the "viem" and "wagmi" entries in swap-widget's
package.json to align with the root and other packages (use viem ^2.40.3 to
match others and set wagmi to the same caret range as root, e.g., ^2.9.2), or if
a different version is required, add a clear comment in swap-widget's
package.json and a short rationale in the package README explaining why it needs
a unique wagmi version; ensure the change references the swap-widget
package.json dependencies block so monorepo resolution remains consistent.
| | `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 https://cloud.walletconnect.com. | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrap bare URL in angle brackets or as a markdown link.
Static analysis flagged a bare URL. Markdown best practice is to wrap URLs.
📝 Suggested fix
-| `walletConnectProjectId` | `string` | - | WalletConnect project ID for the built-in wallet connection. Get one at https://cloud.walletconnect.com. |
+| `walletConnectProjectId` | `string` | - | WalletConnect project ID for the built-in wallet connection. Get one at <https://cloud.walletconnect.com>. |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | `walletConnectProjectId` | `string` | - | WalletConnect project ID for the built-in wallet connection. Get one at https://cloud.walletconnect.com. | | |
| | `walletConnectProjectId` | `string` | - | WalletConnect project ID for the built-in wallet connection. Get one at <https://cloud.walletconnect.com>. | |
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
79-79: Bare URL used
(MD034, no-bare-urls)
🤖 Prompt for AI Agents
In @packages/swap-widget/README.md at line 79, The table cell documenting
`walletConnectProjectId` contains a bare URL; update the README row so the URL
is either wrapped in angle brackets like <https://cloud.walletconnect.com> or
formatted as a Markdown link such as https://cloud.walletconnect.com (display
text)[https://cloud.walletconnect.com] to eliminate the bare-URL lint warning
and preserve link behavior in the `walletConnectProjectId` description.
| const response = await fetch(url.toString(), fetchOptions) | ||
| if (!response.ok) { | ||
| throw new Error(`API error: ${response.status} ${response.statusText}`) | ||
| } | ||
| return response.json() as Promise<T> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API calls lack timeout handling.
Per coding guidelines for API files, timeouts should be used for API calls. The current implementation has no timeout, which could cause requests to hang indefinitely.
🔧 Add timeout using AbortController
const fetchWithConfig = async <T>(
endpoint: string,
params?: Record<string, string>,
method: 'GET' | 'POST' = 'GET',
+ timeoutMs = 30000,
): Promise<T> => {
const url = new URL(`${baseUrl}${endpoint}`)
const headers: Record<string, string> = {
'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 }
+ const fetchOptions: RequestInit = { headers, method, signal: controller.signal }
// ... rest of the function
- const response = await fetch(url.toString(), fetchOptions)
+ const response = await fetch(url.toString(), fetchOptions).finally(() => {
+ clearTimeout(timeoutId)
+ })🤖 Prompt for AI Agents
In @packages/swap-widget/src/api/client.ts around lines 36 - 40, Add timeout
handling to the fetch by creating an AbortController, attaching its signal to
the existing fetchOptions before calling fetch(url.toString(), fetchOptions),
and starting a setTimeout that calls controller.abort() after the configured
timeout (e.g., default or provided). Ensure you clear the timeout on
success/failure, handle the abort error (throw a clear timeout-specific Error or
rethrow), and keep the existing response.ok check and return response.json() as
Promise<T>; update usage of fetchOptions and url and reference the
AbortController signal when initiating fetch.
| .ssw-address-input-wrapper.ssw-invalid { | ||
| border-color: #ef4444; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the theme variable for error color consistency.
The hardcoded #ef4444 differs from --ssw-error: #f44336 defined in SwapWidget.css. Using the CSS variable ensures consistent theming and easier maintenance.
🎨 Proposed fix
.ssw-address-input-wrapper.ssw-invalid {
- border-color: #ef4444;
+ border-color: var(--ssw-error);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .ssw-address-input-wrapper.ssw-invalid { | |
| border-color: #ef4444; | |
| } | |
| .ssw-address-input-wrapper.ssw-invalid { | |
| border-color: var(--ssw-error); | |
| } |
🤖 Prompt for AI Agents
In @packages/swap-widget/src/components/AddressInputModal.css around lines 39 -
41, Replace the hardcoded error color in the
.ssw-address-input-wrapper.ssw-invalid rule by using the theme CSS variable to
ensure consistency: change the value from #ef4444 to var(--ssw-error) in the
.ssw-address-input-wrapper.ssw-invalid selector so it uses the --ssw-error value
defined in SwapWidget.css.
| await client.sendTransaction({ | ||
| to: sellAssetAddress as `0x${string}`, | ||
| data: approvalData, | ||
| value: BigInt(0), | ||
| chain, | ||
| account: walletAddress as `0x${string}`, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Approval transaction does not wait for confirmation before proceeding.
The code sends the approval transaction but doesn't wait for it to be mined before sending the swap transaction. This could cause the swap to fail if the approval hasn't been confirmed yet.
🐛 Proposed fix to wait for approval confirmation
+import { waitForTransactionReceipt } from 'viem/actions'
- await client.sendTransaction({
+ const approvalHash = await client.sendTransaction({
to: sellAssetAddress as `0x${string}`,
data: approvalData,
value: BigInt(0),
chain,
account: walletAddress as `0x${string}`,
})
+
+ // Wait for approval to be mined
+ await waitForTransactionReceipt(client, { hash: approvalHash })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await client.sendTransaction({ | |
| to: sellAssetAddress as `0x${string}`, | |
| data: approvalData, | |
| value: BigInt(0), | |
| chain, | |
| account: walletAddress as `0x${string}`, | |
| }) | |
| } | |
| import { waitForTransactionReceipt } from 'viem/actions' | |
| const approvalHash = await client.sendTransaction({ | |
| to: sellAssetAddress as `0x${string}`, | |
| data: approvalData, | |
| value: BigInt(0), | |
| chain, | |
| account: walletAddress as `0x${string}`, | |
| }) | |
| // Wait for approval to be mined | |
| await waitForTransactionReceipt(client, { hash: approvalHash }) | |
| } |
🤖 Prompt for AI Agents
In @packages/swap-widget/src/components/SwapWidget.tsx around lines 246 - 253,
The approval sendTransaction call currently doesn't wait for mining; capture its
returned transaction object (from client.sendTransaction when sending to
sellAssetAddress with approvalData and account walletAddress) into a variable
(e.g., approvalTx) and await its confirmation before proceeding to create/send
the swap transaction—use the client-provided confirmation method
(approvalTx.wait() or client.waitForTransaction(approvalTx.hash)) and
handle/errors/timeouts accordingly so the swap only runs after the approval is
mined.
| const testResponse = await fetch( | ||
| `${COINGECKO_PROXY_URL}/coins/markets?vs_currency=usd&per_page=1&page=1`, | ||
| ).catch(() => null) | ||
|
|
||
| if (!testResponse?.ok) { | ||
| baseUrl = COINGECKO_DIRECT_URL | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Proxy availability check lacks timeout.
The test fetch to check if the proxy is available has no timeout, which could cause the entire market data fetch to hang indefinitely if the proxy is slow to respond.
🔧 Add timeout using AbortController
+ 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)🤖 Prompt for AI Agents
In @packages/swap-widget/src/hooks/useMarketData.ts around lines 66 - 72, The
proxy availability check using the fetch to
`${COINGECKO_PROXY_URL}/coins/markets?...` (the testResponse logic in
useMarketData) needs a timeout to avoid hanging; create an AbortController, pass
controller.signal into the fetch call, set a short timeout (e.g., 2–5s) to call
controller.abort(), and ensure the catch branch treats abort/errors as an
unavailable proxy (so baseUrl = COINGECKO_DIRECT_URL when the fetch is aborted
or fails). Also clear the timeout after fetch completes to avoid leaks and keep
the existing logic that checks testResponse?.ok to decide the baseUrl.
| return useQuery({ | ||
| queryKey: [ | ||
| 'swapQuote', | ||
| sellAssetId, | ||
| buyAssetId, | ||
| sellAmountCryptoBaseUnit, | ||
| sendAddress, | ||
| receiveAddress, | ||
| swapperName, | ||
| ], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing slippageTolerancePercentageDecimal in queryKey causes stale cache issues.
The slippageTolerancePercentageDecimal parameter is passed to the API (line 57) but not included in the queryKey. When users change slippage tolerance, React Query will return the cached result instead of fetching a new quote with the updated slippage.
🐛 Proposed fix
return useQuery({
queryKey: [
'swapQuote',
sellAssetId,
buyAssetId,
sellAmountCryptoBaseUnit,
sendAddress,
receiveAddress,
swapperName,
+ slippageTolerancePercentageDecimal,
],📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return useQuery({ | |
| queryKey: [ | |
| 'swapQuote', | |
| sellAssetId, | |
| buyAssetId, | |
| sellAmountCryptoBaseUnit, | |
| sendAddress, | |
| receiveAddress, | |
| swapperName, | |
| ], | |
| return useQuery({ | |
| queryKey: [ | |
| 'swapQuote', | |
| sellAssetId, | |
| buyAssetId, | |
| sellAmountCryptoBaseUnit, | |
| sendAddress, | |
| receiveAddress, | |
| swapperName, | |
| slippageTolerancePercentageDecimal, | |
| ], |
🤖 Prompt for AI Agents
In @packages/swap-widget/src/hooks/useSwapQuote.ts around lines 29 - 38, The
queryKey used in useSwapQuote is missing the slippageTolerancePercentageDecimal
so React Query returns stale quotes when slippage changes; update the queryKey
array in the useQuery call inside useSwapQuote to include
slippageTolerancePercentageDecimal (the same param you pass to the API) so the
cache is keyed by slippage and a new quote is fetched whenever it changes.
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrap case block in braces to fix lexical scoping.
The const expectedPrefix declaration in the switch case can be erroneously accessed by other cases. As per the static analysis hint, wrap it in a block.
🔧 Suggested fix
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
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 '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 | |
| } |
🧰 Tools
🪛 Biome (2.1.2)
[error] 162-162: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.
The declaration is defined in this switch clause:
Safe fix: Wrap the declaration in a block.
(lint/correctness/noSwitchDeclarations)
🤖 Prompt for AI Agents
In @packages/swap-widget/src/utils/addressValidation.ts around lines 161 - 169,
The 'cosmos' switch case in validateAddress (or the function containing this
switch) declares const expectedPrefix which can leak to other cases; wrap the
entire 'case "cosmos":' body in braces { ... } so expectedPrefix (and any other
const/let declarations like trimmedAddress usage) are block-scoped, keeping the
call sites getCosmosPrefix and isValidCosmosAddress unchanged and returning the
same { valid: false, error: ... } on failure.
| case 'cosmos': | ||
| const prefix = getCosmosPrefix(chainId) | ||
| return prefix ? `${prefix}1...` : 'Enter address' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrap case block in braces to fix lexical scoping.
Same issue as in validateAddress - the const prefix declaration needs block scoping.
🔧 Suggested fix
case 'cosmos':
+ {
const prefix = getCosmosPrefix(chainId)
return prefix ? `${prefix}1...` : 'Enter address'
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case 'cosmos': | |
| const prefix = getCosmosPrefix(chainId) | |
| return prefix ? `${prefix}1...` : 'Enter address' | |
| case 'cosmos': | |
| { | |
| const prefix = getCosmosPrefix(chainId) | |
| return prefix ? `${prefix}1...` : 'Enter address' | |
| } |
🧰 Tools
🪛 Biome (2.1.2)
[error] 205-205: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.
The declaration is defined in this switch clause:
Safe fix: Wrap the declaration in a block.
(lint/correctness/noSwitchDeclarations)
🤖 Prompt for AI Agents
In @packages/swap-widget/src/utils/addressValidation.ts around lines 204 - 206,
The 'cosmos' switch case declares const prefix without block scope causing
lexical scoping issues; wrap the case body in braces so the prefix is
block-scoped — i.e., change the case 'cosmos' branch to: case 'cosmos': { const
prefix = getCosmosPrefix(chainId); return prefix ? `${prefix}1...` : 'Enter
address'; } — ensuring the const is scoped to that case.
Description
Introduces an embeddable swap widget package (
@shapeshiftoss/swap-widget) that enables multi-chain token swaps using ShapeShift's aggregation API. This is a standalone React component that can be integrated into third-party applications with minimal configuration.Key Features
walletClientpropenableWalletConnectionpropallowedChainIds,disabledChainIds,allowedAssetIds,disabledAssetIdspropsdefaultReceiveAddressprop for locking destination addressComponents
SwapWidget- Main entry pointTokenSelectModal- Virtualized token selector with searchQuotesModal- Compare quotes from multiple swappersSettingsModal- Slippage and receive address configurationAddressInputModal- External receive address inputWalletProvider- RainbowKit wallet connection wrapperAPI Hooks
useAssets/useAssetById/useAssetSearch/useAssetsByChainId- Asset data fetchinguseChains- Chain metadatauseBalances- Wallet balance fetching (EVM)useMarketData- USD price datauseSwapRates- Rate/quote fetching with throttlinguseSwapQuote- Full quote fetching for executionDeployment
Risk
Low - This is a new standalone package that does not affect the main web application.
Testing
Engineering
yarn dev:swap-widgetto start the demo appOperations
This is a standalone widget package and doesn't affect the main app.
Screenshots
Widget is deployed at: https://widget.shapeshift.com
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.