From 399b86f406495d7b873985a43b1949dfc71b7398 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:51:57 -0400 Subject: [PATCH 01/15] feat: reduce useEffect usage (#1120) * feat: initial * feat: round two * feat: round three * chore: update CLAUDE.md * fix: price impact confirmation resets (#1124) Replace remembered confirmation keys with keyed resettable state in the deposit and withdraw widgets. This preserves the high-price-impact warning behavior without reintroducing a reset effect, so a user returning from quote B back to quote A must acknowledge the warning again. --------- Co-authored-by: rossgalloway <58150151+rossgalloway@users.noreply.github.com> --- CLAUDE.md | 18 +++ src/components/Image.tsx | 16 +- .../pages/vaults/[chainID]/[address].tsx | 25 ++- .../compare/SwipeableCompareCarousel.tsx | 11 +- .../components/list/VaultsListEmpty.tsx | 7 +- .../components/widget/TokenSelector.tsx | 35 ++-- .../components/widget/deposit/index.tsx | 32 ++-- .../pages/vaults/components/widget/index.tsx | 19 +-- .../components/widget/withdraw/index.tsx | 50 +++--- .../pages/vaults/hooks/useDebouncedInput.ts | 35 ++-- src/components/pages/vaults/hooks/useInput.ts | 14 +- .../pages/vaults/hooks/useVaultsPageModel.ts | 149 +++++------------- src/components/pages/vaults/index.tsx | 9 +- src/components/shared/components/Meta.tsx | 5 - src/components/shared/contexts/useWeb3.tsx | 12 +- src/components/shared/contexts/useYearn.tsx | 13 +- .../shared/hooks/useOptimisticValue.ts | 27 ++++ src/contexts/useDevFlags.tsx | 55 +++---- 18 files changed, 236 insertions(+), 296 deletions(-) create mode 100644 src/components/shared/hooks/useOptimisticValue.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3e0c77ffb..195593384 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,24 @@ Naming: - Utilities: camelCase (`format.ts`) - Types: T-prefixed (`TSortDirection`, `TVaultType`) +### useEffect — prefer alternatives + +Avoid `useEffect` when a better primitive exists. Most `useEffect` usage hides derived state, duplicates event handling, or re-implements what TanStack Query already provides. + +**Prefer these instead:** +- **Derived state** — compute inline or with `useMemo` instead of `useEffect(() => setX(f(y)), [y])` +- **Event handlers** — do work directly in `onClick`/`onChange` instead of setting a flag for an effect to pick up +- **TanStack Query** — use `useQuery`/`useMutation` for data fetching, never `useEffect` + `fetch` + `setState` +- **`key` prop for reset** — use `` to remount instead of `useEffect` that resets state when an ID changes +- **Conditional rendering** — render children only when preconditions are met (e.g., `{!isLoading && }`) instead of guarding inside an effect + +**When `useEffect` is acceptable:** +- One-time DOM/browser API setup on mount (IntersectionObserver, event listeners, focus) +- Third-party library lifecycle (init/destroy) +- Cases where no declarative alternative exists + +When writing a new `useEffect`, add a brief comment explaining why an alternative does not apply. + ## Architecture **Tech stack:** React 19, Vite, React Router (lazy-loaded), Tailwind CSS 4, TanStack Query, Wagmi/Viem/RainbowKit diff --git a/src/components/Image.tsx b/src/components/Image.tsx index 7276a8109..bfab6794c 100644 --- a/src/components/Image.tsx +++ b/src/components/Image.tsx @@ -42,6 +42,7 @@ function Image(props: CustomImageProps): ReactElement { } = props const [imageSrc, setImageSrc] = useState(src) + const [prevSrc, setPrevSrc] = useState(src) const [isVisible, setIsVisible] = useState(loading !== 'lazy' || priority === true) const [isLoading, setIsLoading] = useState(true) const [hasError, setHasError] = useState(false) @@ -49,6 +50,14 @@ function Image(props: CustomImageProps): ReactElement { const imgRef = useRef(null) const observerRef = useRef(null) + // Render-time state adjustment: reset when src prop changes + if (src !== prevSrc) { + setPrevSrc(src) + setImageSrc(src) + setHasError(false) + setIsLoading(true) + } + // Set up IntersectionObserver for lazy loading useEffect(() => { if (loading !== 'lazy' || priority || !imageRef.current) return @@ -75,13 +84,6 @@ function Image(props: CustomImageProps): ReactElement { } }, [loading, priority]) - // Reset states when src changes - useEffect(() => { - setImageSrc(src) - setHasError(false) - setIsLoading(true) - }, [src]) - // Handle already-cached images where onLoad might not fire useEffect(() => { if (!isVisible) return diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index 2575cb10e..910d11f9f 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -563,15 +563,13 @@ function Index(): ReactElement | null { } | null>(null) const tourSectionsRef = useRef | null>(null) - useEffect(() => { - setWidgetMode((previous) => (widgetActions.includes(previous) ? previous : widgetActions[0])) - }, [widgetActions]) - - useEffect(() => { - if (!widgetActions.includes(mobileDrawerAction)) { - setMobileDrawerAction(widgetActions[0]) - } - }, [mobileDrawerAction, widgetActions]) + // Render-time state adjustment: keep mode valid when available actions change + if (!widgetActions.includes(widgetMode)) { + setWidgetMode(widgetActions[0]) + } + if (!widgetActions.includes(mobileDrawerAction)) { + setMobileDrawerAction(widgetActions[0]) + } useEffect(() => { if (typeof window === 'undefined') return @@ -833,11 +831,10 @@ function Index(): ReactElement | null { enabled: renderableSections.length > 0 && !isProgrammaticScroll }) - useEffect(() => { - if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) { - setActiveSection(renderableSections[0].key) - } - }, [renderableSections, activeSection]) + // Render-time state adjustment: ensure active section is valid + if (!renderableSections.some((section) => section.key === activeSection) && renderableSections[0]) { + setActiveSection(renderableSections[0].key) + } useEffect(() => { if (!pendingSectionKey || !isHeaderCompressed) return diff --git a/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx b/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx index 352a7b8f0..c9a8a55fe 100644 --- a/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx +++ b/src/components/pages/vaults/components/compare/SwipeableCompareCarousel.tsx @@ -4,7 +4,7 @@ import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconChevron } from '@shared/icons/IconChevron' import { cl } from '@shared/utils' import { motion, type PanInfo, useAnimation } from 'framer-motion' -import { type ReactElement, useCallback, useEffect, useState } from 'react' +import { type ReactElement, useCallback, useState } from 'react' type TSwipeableCompareCarouselProps = { vaults: TKongVaultInput[] @@ -20,11 +20,10 @@ export function SwipeableCompareCarousel({ vaults, onRemove }: TSwipeableCompare const maxIndex = vaults.length - 1 - useEffect(() => { - if (currentIndex > maxIndex) { - setCurrentIndex(Math.max(0, maxIndex)) - } - }, [currentIndex, maxIndex]) + // Render-time state adjustment: clamp index when vaults are removed + if (currentIndex > maxIndex) { + setCurrentIndex(Math.max(0, maxIndex)) + } const goToIndex = useCallback( (index: number) => { diff --git a/src/components/pages/vaults/components/list/VaultsListEmpty.tsx b/src/components/pages/vaults/components/list/VaultsListEmpty.tsx index 6ef915873..6724b449e 100644 --- a/src/components/pages/vaults/components/list/VaultsListEmpty.tsx +++ b/src/components/pages/vaults/components/list/VaultsListEmpty.tsx @@ -3,7 +3,7 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { Button } from '@shared/components/Button' import { EmptyState } from '@shared/components/EmptyState' import { cl } from '@shared/utils' -import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { type ReactElement, useCallback, useMemo, useState } from 'react' type TVaultsBlockingFilterAction = { key: string @@ -46,9 +46,10 @@ export function VaultsListEmpty({ ) const selectedBlockingFilterKeys = useMemo(() => new Set(selectedBlockingFilters), [selectedBlockingFilters]) - useEffect(() => { + // Render-time state adjustment: prune selections when available options change + if (selectedBlockingFilters.some((key) => !availableBlockingFilterKeys.has(key))) { setSelectedBlockingFilters((prev) => prev.filter((key) => availableBlockingFilterKeys.has(key))) - }, [availableBlockingFilterKeys]) + } const toggleBlockingFilter = useCallback((key: string): void => { setSelectedBlockingFilters((prev) => (prev.includes(key) ? prev.filter((entry) => entry !== key) : [...prev, key])) diff --git a/src/components/pages/vaults/components/widget/TokenSelector.tsx b/src/components/pages/vaults/components/widget/TokenSelector.tsx index af7fb1a1a..c67db93e6 100644 --- a/src/components/pages/vaults/components/widget/TokenSelector.tsx +++ b/src/components/pages/vaults/components/widget/TokenSelector.tsx @@ -10,7 +10,7 @@ import { useWallet } from '@shared/contexts/useWallet' import { useYearn } from '@shared/contexts/useYearn' import { useTokenList } from '@shared/contexts/WithTokenList' import type { TToken } from '@shared/types' -import { cl, formatTAmount, isZeroAddress, toAddress } from '@shared/utils' +import { cl, formatTAmount, toAddress } from '@shared/utils' import { type FC, useCallback, useMemo, useState } from 'react' import { isAddress } from 'viem' import { CloseIcon } from './shared/Icons' @@ -142,11 +142,21 @@ export const TokenSelector: FC = ({ const [searchText, setSearchText] = useState('') const [selectedChainId, setSelectedChainId] = useState(chainId) const { getToken, isLoading, balances } = useWallet() - const { tokenLists } = useTokenList() - const { allVaults, getPrice } = useYearn() - const customAddress = useMemo( - () => (searchText && isAddress(searchText) ? (searchText as `0x${string}`) : undefined), - [searchText] + + // Derived: treat valid address input as custom token + const customAddress = searchText && isAddress(searchText) ? (searchText as `0x${string}`) : undefined + + // Available chains - you can expand this list as needed + const availableChains = useMemo( + () => [ + { id: 1, name: 'Ethereum' }, + { id: 10, name: 'Optimism' }, + { id: 137, name: 'Polygon' }, + { id: 42161, name: 'Arbitrum' }, + { id: 8453, name: 'Base' }, + { id: 747474, name: 'Katana' } + ], + [] ) const priorityTokenAddresses = useMemo( @@ -368,18 +378,7 @@ export const TokenSelector: FC = ({ topTokenAddresses, getTokenUsdValue }) - }, [ - tokens, - mode, - limitTokens, - combinedExcludeTokens, - searchText, - yearnKnownTokenAddresses, - explicitTokenAddresses, - minValueExemptTokenAddresses, - topTokenAddresses, - getTokenUsdValue - ]) + }, [tokens, limitTokens, excludeTokens, searchText]) const handleSelect = useCallback( (address: `0x${string}`) => { diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index ebffed4a7..a8236951c 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -165,10 +165,6 @@ export function WidgetDeposit({ const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const [priceImpactAcceptance, setPriceImpactAcceptance] = useState<{ key: string; isAccepted: boolean }>({ - key: '', - isAccepted: false - }) const appliedPrefillRef = useRef(null) const { @@ -273,11 +269,10 @@ export function WidgetDeposit({ setShowTokenSelector }) - useEffect(() => { - if (!shouldCollapseDetails && isDetailsPanelOpen) { - setIsDetailsPanelOpen(false) - } - }, [isDetailsPanelOpen, shouldCollapseDetails]) + // Render-time state adjustment: close panel when collapse is disabled + if (!shouldCollapseDetails && isDetailsPanelOpen) { + setIsDetailsPanelOpen(false) + } const { routeType, activeFlow } = useDepositFlow({ depositToken, @@ -415,8 +410,21 @@ export function WidgetDeposit({ activeFlow.periphery.routerAddress, activeFlow.periphery.expectedOut ]) - const hasAcceptedPriceImpact = - priceImpactAcceptance.key === priceImpactAcceptanceKey && priceImpactAcceptance.isAccepted + + const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{ + key: string + isAccepted: boolean + }>({ + key: priceImpactAcceptanceKey, + isAccepted: false + }) + if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) { + setPriceImpactAcceptanceState({ + key: priceImpactAcceptanceKey, + isAccepted: false + }) + } + const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted const formattedDepositAmount = formatTAmount({ value: depositAmount.bn, decimals: inputToken?.decimals ?? 18 }) const needsApproval = !isNativeToken && !activeFlow.periphery.isAllowanceSufficient @@ -629,7 +637,7 @@ export function WidgetDeposit({ type="checkbox" checked={hasAcceptedPriceImpact} onChange={(e) => - setPriceImpactAcceptance({ + setPriceImpactAcceptanceState({ key: priceImpactAcceptanceKey, isAccepted: e.target.checked }) diff --git a/src/components/pages/vaults/components/widget/index.tsx b/src/components/pages/vaults/components/widget/index.tsx index 85f940577..31c04939e 100644 --- a/src/components/pages/vaults/components/widget/index.tsx +++ b/src/components/pages/vaults/components/widget/index.tsx @@ -12,15 +12,7 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { WidgetActionType as ActionType } from '@pages/vaults/types' import type { TAddress } from '@shared/types' import { cl, isZeroAddress, toAddress } from '@shared/utils' -import { - type ForwardedRef, - forwardRef, - type ReactElement, - type ReactNode, - useEffect, - useImperativeHandle, - useState -} from 'react' +import { type ForwardedRef, forwardRef, type ReactElement, type ReactNode, useImperativeHandle, useState } from 'react' import { WidgetDeposit } from './deposit' import { WidgetMigrate } from './migrate' import { WidgetWithdraw } from './withdraw' @@ -108,11 +100,10 @@ export const Widget = forwardRef(function Widget( } })) - useEffect(() => { - if (mode === undefined) { - setInternalMode(actions[0]) - } - }, [actions, mode]) + // Render-time state adjustment: keep internal mode valid when actions change + if (mode === undefined && !actions.includes(internalMode)) { + setInternalMode(actions[0]) + } function renderSelectedComponent(): ReactElement { switch (currentMode) { diff --git a/src/components/pages/vaults/components/widget/withdraw/index.tsx b/src/components/pages/vaults/components/widget/withdraw/index.tsx index c69abe5cd..f1d43f899 100644 --- a/src/components/pages/vaults/components/widget/withdraw/index.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/index.tsx @@ -142,10 +142,6 @@ export function WidgetWithdraw({ const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [withdrawalSource, setWithdrawalSource] = useState(stakingAddress ? null : 'vault') const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const [priceImpactAcceptance, setPriceImpactAcceptance] = useState<{ key: string; isAccepted: boolean }>({ - key: '', - isAccepted: false - }) const appliedPrefillRef = useRef(null) const [fallbackStep, setFallbackStep] = useState<'unstake' | 'withdraw'>('unstake') const [redeemSharesOverride, setRedeemSharesOverride] = useState(0n) @@ -194,17 +190,13 @@ export function WidgetWithdraw({ const hasBothBalances = hasVaultBalance && hasStakingBalance const singleSource = resolveWithdrawalSource(hasVaultBalance, hasStakingBalance) - useEffect(() => { - if (singleSource) { - setWithdrawalSource(singleSource) - } - }, [singleSource]) - - useEffect(() => { - if (!collapseDetails && isDetailsPanelOpen) { - setIsDetailsPanelOpen(false) - } - }, [collapseDetails, isDetailsPanelOpen]) + // Render-time state adjustments + if (singleSource && withdrawalSource !== singleSource) { + setWithdrawalSource(singleSource) + } + if (!collapseDetails && isDetailsPanelOpen) { + setIsDetailsPanelOpen(false) + } useResetEnsoSelection({ ensoEnabled, @@ -369,11 +361,10 @@ export function WidgetWithdraw({ : directWithdrawFlow.actions.prepareWithdraw const effectiveWithdrawAmountRaw = expectedOutOverride ?? withdrawAmount.bn - useEffect(() => { - if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) { - setOptimisticApprovedShares(null) - } - }, [activeFlow.periphery.allowance, optimisticApprovedShares]) + // Render-time adjustment: clear optimistic approval when actual allowance catches up + if (optimisticApprovedShares !== null && activeFlow.periphery.allowance >= optimisticApprovedShares) { + setOptimisticApprovedShares(null) + } useEffect(() => { if (optimisticApprovedShares === null) return @@ -516,8 +507,21 @@ export function WidgetWithdraw({ activeFlow.periphery.routerAddress, effectiveExpectedOut ]) - const hasAcceptedPriceImpact = - priceImpactAcceptance.key === priceImpactAcceptanceKey && priceImpactAcceptance.isAccepted + + const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{ + key: string + isAccepted: boolean + }>({ + key: priceImpactAcceptanceKey, + isAccepted: false + }) + if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) { + setPriceImpactAcceptanceState({ + key: priceImpactAcceptanceKey, + isAccepted: false + }) + } + const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted const canOpenTokenSelector = ensoEnabled && !disableTokenSelector const shouldShowZapUi = !isBaseWithdrawToken @@ -779,7 +783,7 @@ export function WidgetWithdraw({ type="checkbox" checked={hasAcceptedPriceImpact} onChange={(e) => - setPriceImpactAcceptance({ + setPriceImpactAcceptanceState({ key: priceImpactAcceptanceKey, isAccepted: e.target.checked }) diff --git a/src/components/pages/vaults/hooks/useDebouncedInput.ts b/src/components/pages/vaults/hooks/useDebouncedInput.ts index 9820fa50a..c7bb4408f 100644 --- a/src/components/pages/vaults/hooks/useDebouncedInput.ts +++ b/src/components/pages/vaults/hooks/useDebouncedInput.ts @@ -44,26 +44,23 @@ const tryParseString = (value: string, decimals: number) => { export const useDebouncedInput = (decimals = 18, debounceMs = 500): UseDebouncedInputReturnValue => { const [formValue, setFormValue] = useState('') const [debouncedFormValue, setDebouncedFormValue] = useState('') - const [isDebouncing, setIsDebouncing] = useState(false) const activity = useState(false) - // Debounce the form value - useEffect(() => { - if (formValue === debouncedFormValue) { - setIsDebouncing(false) - return - } + // Render-time state adjustment: trim form value when decimals decrease + const [whole, fraction] = formValue.split('.') + if (fraction && fraction.length > decimals) { + setFormValue(`${whole}.${fraction.slice(0, decimals)}`) + } - setIsDebouncing(true) - const handler = setTimeout(() => { - setDebouncedFormValue(formValue) - setIsDebouncing(false) - }, debounceMs) + // Derived: debouncing when form value differs from debounced value + const isDebouncing = formValue !== debouncedFormValue - return () => { - clearTimeout(handler) - } + // Timer to settle debounced value — external sync (useEffect required) + useEffect(() => { + if (formValue === debouncedFormValue) return + const handler = setTimeout(() => setDebouncedFormValue(formValue), debounceMs) + return () => clearTimeout(handler) }, [formValue, debounceMs]) // Handle callback, no change if number is invalid @@ -83,14 +80,6 @@ export const useDebouncedInput = (decimals = 18, debounceMs = 500): UseDebounced [decimals] ) - // Trim existing if beyond decimal limit - useEffect(() => { - setFormValue((prev) => { - const [whole, fraction] = prev.split('.') - return fraction && fraction.length > decimals ? `${whole}.${fraction.slice(0, decimals)}` : prev - }) - }, [decimals]) - // State change on formValue / decimals const state = useMemo(() => { const bn = tryParseString(formValue, decimals) || 0n diff --git a/src/components/pages/vaults/hooks/useInput.ts b/src/components/pages/vaults/hooks/useInput.ts index 55c77e5c3..087af043b 100644 --- a/src/components/pages/vaults/hooks/useInput.ts +++ b/src/components/pages/vaults/hooks/useInput.ts @@ -1,5 +1,5 @@ import { exactToSimple } from '@shared/utils' -import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' +import { type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useMemo, useState } from 'react' import { parseUnits } from 'viem' export interface InputValue { @@ -61,13 +61,11 @@ const createUseInputHook = [setFormValue, decimals] ) - // Trim existing if beyond decimal limit - useEffect(() => { - setFormValue((prev) => { - const [whole, fraction] = prev.split('.') - return fraction && fraction.length > decimals ? `${whole}.${fraction.slice(0, decimals)}` : prev - }) - }, [setFormValue, decimals]) + // Render-time state adjustment: trim form value when decimals decrease + const [whole, fraction] = formValue.split('.') + if (fraction && fraction.length > decimals) { + setFormValue(`${whole}.${fraction.slice(0, decimals)}`) + } // State change on formValue / decimals const state = useMemo(() => { diff --git a/src/components/pages/vaults/hooks/useVaultsPageModel.ts b/src/components/pages/vaults/hooks/useVaultsPageModel.ts index 31680164c..153f6e880 100644 --- a/src/components/pages/vaults/hooks/useVaultsPageModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsPageModel.ts @@ -49,6 +49,7 @@ import type { TMultiSelectOptionProps } from '@shared/components/MultiSelectDrop import { TokenLogo } from '@shared/components/TokenLogo' import { useWeb3 } from '@shared/contexts/useWeb3' import { usePrefetchYearnVaults } from '@shared/hooks/useFetchYearnVaults' +import { useOptimisticValue } from '@shared/hooks/useOptimisticValue' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import type { TSortDirection } from '@shared/types' import type { RefObject } from 'react' @@ -69,6 +70,24 @@ import { VAULTS_FILTERS_STORAGE_KEY } from './vaultsFiltersStorage' const DEFAULT_VAULT_TYPES = ['multi', 'single'] const DEFAULT_SORT_BY: TPossibleSortBy = 'tvl' +const areArraysEquivalent = ( + a: Array | null | undefined, + b: Array | null | undefined +): boolean => { + const normalize = (value: Array | null | undefined): Array => { + if (!value || value.length === 0) { + return [] + } + return [...new Set(value)].sort((left, right) => String(left).localeCompare(String(right))) + } + const normalizedA = normalize(a) + const normalizedB = normalize(b) + if (normalizedA.length !== normalizedB.length) { + return false + } + return normalizedA.every((value, index) => value === normalizedB[index]) +} + type TVaultsPinnedSection = { key: string vaults: TKongVaultInput[] @@ -255,16 +274,6 @@ export function useVaultsPageModel(): TVaultsPageModel { usePrefetchYearnVaults(vaultType === 'v3') - useEffect(() => { - if (sortBy !== 'featuringScore') { - return - } - onChangeSortBy(DEFAULT_SORT_BY) - if (sortDirection !== 'desc') { - onChangeSortDirection('desc') - } - }, [sortBy, sortDirection, onChangeSortBy, onChangeSortDirection]) - const varsRef = useRef(null) const filtersRef = useRef(null) const searchValue = search ?? '' @@ -279,16 +288,22 @@ export function useVaultsPageModel(): TVaultsPageModel { }) ?? false const shouldCollapseChips = isBelow1000 const shouldStackFilters = isBelow1000 && !isBelow768 - const [optimisticVaultType, setOptimisticVaultType] = useState(null) - const [optimisticChains, setOptimisticChains] = useState(null) - const [optimisticTypes, setOptimisticTypes] = useState(null) - const [optimisticCategories, setOptimisticCategories] = useState(null) - const [optimisticAggressiveness, setOptimisticAggressiveness] = useState(null) - const [optimisticUnderlyingAssets, setOptimisticUnderlyingAssets] = useState(null) - const [optimisticMinTvl, setOptimisticMinTvl] = useState(null) - const [optimisticShowLegacyVaults, setOptimisticShowLegacyVaults] = useState(null) - const [optimisticShowHiddenVaults, setOptimisticShowHiddenVaults] = useState(null) - const [optimisticShowStrategies, setOptimisticShowStrategies] = useState(null) + + // Optimistic UI — show immediate feedback while URL state catches up + const [displayedVaultType, setOptimisticVaultType] = useOptimisticValue(vaultType) + const [displayedChains, setOptimisticChains] = useOptimisticValue(chains, areArraysEquivalent) + const [displayedTypes, setOptimisticTypes] = useOptimisticValue(types, areArraysEquivalent) + const [displayedCategories, setOptimisticCategories] = useOptimisticValue(categories, areArraysEquivalent) + const [displayedAggressiveness, setOptimisticAggressiveness] = useOptimisticValue(aggressiveness, areArraysEquivalent) + const [displayedUnderlyingAssets, setOptimisticUnderlyingAssets] = useOptimisticValue( + underlyingAssets, + areArraysEquivalent + ) + const [displayedMinTvl, setOptimisticMinTvl] = useOptimisticValue(minTvl) + const [displayedShowLegacyVaults, setOptimisticShowLegacyVaults] = useOptimisticValue(showLegacyVaults) + const [displayedShowHiddenVaults, setOptimisticShowHiddenVaults] = useOptimisticValue(showHiddenVaults) + const [displayedShowStrategies, setOptimisticShowStrategies] = useOptimisticValue(showStrategies) + const listChains = useDeferredValue(chains) const listTypes = useDeferredValue(types) const listCategories = useDeferredValue(categories) @@ -298,95 +313,7 @@ export function useVaultsPageModel(): TVaultsPageModel { const listShowLegacyVaults = useDeferredValue(showLegacyVaults) const listShowHiddenVaults = useDeferredValue(showHiddenVaults) const listShowStrategies = useDeferredValue(showStrategies) - const areArraysEquivalent = useCallback( - (a: Array | null | undefined, b: Array | null | undefined): boolean => { - const normalize = (value: Array | null | undefined): Array => { - if (!value || value.length === 0) { - return [] - } - return [...new Set(value)].sort((left, right) => String(left).localeCompare(String(right))) - } - const normalizedA = normalize(a) - const normalizedB = normalize(b) - if (normalizedA.length !== normalizedB.length) { - return false - } - return normalizedA.every((value, index) => value === normalizedB[index]) - }, - [] - ) - - useEffect(() => { - if (optimisticVaultType && optimisticVaultType === vaultType) { - setOptimisticVaultType(null) - } - }, [optimisticVaultType, vaultType]) - - useEffect(() => { - if (optimisticChains !== null && areArraysEquivalent(optimisticChains, chains)) { - setOptimisticChains(null) - } - }, [optimisticChains, chains, areArraysEquivalent]) - - useEffect(() => { - if (optimisticTypes !== null && areArraysEquivalent(optimisticTypes, types)) { - setOptimisticTypes(null) - } - }, [optimisticTypes, types, areArraysEquivalent]) - - useEffect(() => { - if (optimisticCategories !== null && areArraysEquivalent(optimisticCategories, categories)) { - setOptimisticCategories(null) - } - }, [optimisticCategories, categories, areArraysEquivalent]) - - useEffect(() => { - if (optimisticAggressiveness !== null && areArraysEquivalent(optimisticAggressiveness, aggressiveness)) { - setOptimisticAggressiveness(null) - } - }, [optimisticAggressiveness, aggressiveness, areArraysEquivalent]) - - useEffect(() => { - if (optimisticUnderlyingAssets !== null && areArraysEquivalent(optimisticUnderlyingAssets, underlyingAssets)) { - setOptimisticUnderlyingAssets(null) - } - }, [optimisticUnderlyingAssets, underlyingAssets, areArraysEquivalent]) - - useEffect(() => { - if (optimisticMinTvl !== null && optimisticMinTvl === minTvl) { - setOptimisticMinTvl(null) - } - }, [optimisticMinTvl, minTvl]) - - useEffect(() => { - if (optimisticShowLegacyVaults !== null && optimisticShowLegacyVaults === showLegacyVaults) { - setOptimisticShowLegacyVaults(null) - } - }, [optimisticShowLegacyVaults, showLegacyVaults]) - - useEffect(() => { - if (optimisticShowHiddenVaults !== null && optimisticShowHiddenVaults === showHiddenVaults) { - setOptimisticShowHiddenVaults(null) - } - }, [optimisticShowHiddenVaults, showHiddenVaults]) - - useEffect(() => { - if (optimisticShowStrategies !== null && optimisticShowStrategies === showStrategies) { - setOptimisticShowStrategies(null) - } - }, [optimisticShowStrategies, showStrategies]) - - const displayedVaultType = optimisticVaultType ?? vaultType - const displayedChains = optimisticChains ?? chains - const displayedTypes = optimisticTypes ?? types - const displayedCategories = optimisticCategories ?? categories - const displayedAggressiveness = optimisticAggressiveness ?? aggressiveness - const displayedUnderlyingAssets = optimisticUnderlyingAssets ?? underlyingAssets - const displayedMinTvl = optimisticMinTvl ?? minTvl - const displayedShowLegacyVaults = optimisticShowLegacyVaults ?? showLegacyVaults - const displayedShowHiddenVaults = optimisticShowHiddenVaults ?? showHiddenVaults - const displayedShowStrategies = optimisticShowStrategies ?? showStrategies - const hasDisplayedTypesParam = hasTypesParam || optimisticTypes !== null + const hasDisplayedTypesParam = hasTypesParam || displayedTypes !== types const hasListTypesParam = hasTypesParam const resolveV3Types = useCallback( @@ -876,13 +803,13 @@ export function useVaultsPageModel(): TVaultsPageModel { const handleVaultVersionToggle = useCallback( (nextType: TVaultType): void => { - if (nextType === vaultType && !optimisticVaultType) { + if (nextType === displayedVaultType) { return } setOptimisticVaultType(nextType) onChangeVaultType(nextType) }, - [optimisticVaultType, onChangeVaultType, vaultType] + [displayedVaultType, onChangeVaultType] ) const handleToggleVaultType = useCallback( (nextType: 'v3' | 'lp'): void => { diff --git a/src/components/pages/vaults/index.tsx b/src/components/pages/vaults/index.tsx index 9b67fe7b1..c28ec44b9 100644 --- a/src/components/pages/vaults/index.tsx +++ b/src/components/pages/vaults/index.tsx @@ -216,11 +216,10 @@ export default function Index(): ReactElement { return !chainsMatch || isUpdatingProductType }, [activeChains, listChains, isUpdatingProductType, areArraysEquivalent]) - useEffect(() => { - if (isCompareOpen && compareVaultKeys.length < 2) { - setIsCompareOpen(false) - } - }, [compareVaultKeys.length, isCompareOpen]) + // Render-time state adjustment: close compare when fewer than 2 vaults selected + if (isCompareOpen && compareVaultKeys.length < 2) { + setIsCompareOpen(false) + } useEffect(() => { if (!tourState.isOpen) { diff --git a/src/components/shared/components/Meta.tsx b/src/components/shared/components/Meta.tsx index a34d35e60..9d6361d2d 100755 --- a/src/components/shared/components/Meta.tsx +++ b/src/components/shared/components/Meta.tsx @@ -1,5 +1,4 @@ import type { ReactElement } from 'react' -import { useEffect } from 'react' type TMeta = { title: string @@ -11,10 +10,6 @@ type TMeta = { } export function Meta(meta: TMeta): ReactElement { - useEffect(() => { - document.title = meta.title - }, [meta.title]) - return ( <> {meta.title} diff --git a/src/components/shared/contexts/useWeb3.tsx b/src/components/shared/contexts/useWeb3.tsx index cfe10effb..abc1aed5d 100755 --- a/src/components/shared/contexts/useWeb3.tsx +++ b/src/components/shared/contexts/useWeb3.tsx @@ -53,7 +53,6 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement const { openChainModal } = useChainModal() const trackEvent = usePlausible() const [clusters, setClusters] = useState<{ name: string; avatar: string } | undefined>(undefined) - const [isUserConnecting, setIsUserConnecting] = useState(false) const [isFetchingClusters, setIsFetchingClusters] = useState(false) const wasConnectedRef = useRef(false) const previousChainIDRef = useRef(undefined) @@ -210,16 +209,7 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement } }, [address, ensName, isConnected]) - useEffect(() => { - if (isConnecting) { - if (hasUserRequestedConnectionRef.current) { - setIsUserConnecting(true) - } - return - } - - setIsUserConnecting(false) - }, [isConnecting]) + const isUserConnecting = isConnecting && hasUserRequestedConnectionRef.current const isIdentityLoading = Boolean((isEnsLoading && !!address) || isFetchingClusters) const isWalletSafe = connector?.id.toLowerCase().includes('safe') ?? false diff --git a/src/components/shared/contexts/useYearn.tsx b/src/components/shared/contexts/useYearn.tsx index b95489b1c..dbce91a69 100755 --- a/src/components/shared/contexts/useYearn.tsx +++ b/src/components/shared/contexts/useYearn.tsx @@ -9,7 +9,7 @@ import type { TYDaemonEarned } from '@shared/utils/schemas/yDaemonEarnedSchema' import type { TYDaemonPricesChain } from '@shared/utils/schemas/yDaemonPricesSchema' import type { QueryObserverResult } from '@tanstack/react-query' import type { ReactElement } from 'react' -import { createContext, memo, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, memo, useCallback, useContext, useState } from 'react' import { useLocation } from 'react-router' import { deserialize, serialize } from 'wagmi' @@ -90,16 +90,11 @@ export const YearnContextApp = memo(function YearnContextApp({ children }: { chi const isVaultDetailPage = isVaultsRoute && location.pathname.split('/').length === 4 const isPortfolioRoute = location.pathname.startsWith('/portfolio') const shouldEnableVaultList = (isVaultsRoute && !isVaultDetailPage) || isPortfolioRoute - const [isVaultListEnabled, setIsVaultListEnabled] = useState(shouldEnableVaultList) - - useEffect(() => { - if (shouldEnableVaultList) { - setIsVaultListEnabled(true) - } - }, [shouldEnableVaultList]) + const [isManuallyEnabled, setIsManuallyEnabled] = useState(false) + const isVaultListEnabled = shouldEnableVaultList || isManuallyEnabled const enableVaultListFetch = useCallback(() => { - setIsVaultListEnabled(true) + setIsManuallyEnabled(true) }, []) const prices = useFetchYearnPrices() diff --git a/src/components/shared/hooks/useOptimisticValue.ts b/src/components/shared/hooks/useOptimisticValue.ts new file mode 100644 index 000000000..36d45d672 --- /dev/null +++ b/src/components/shared/hooks/useOptimisticValue.ts @@ -0,0 +1,27 @@ +import { type Dispatch, type SetStateAction, useState } from 'react' + +/** + * Manages optimistic state that overrides the actual value until actual catches up. + * + * Replaces the useState + useEffect pattern for clearing optimistic overrides: + * const [optimistic, setOptimistic] = useState(null) + * useEffect(() => { if (optimistic !== null && optimistic === actual) setOptimistic(null) }, [optimistic, actual]) + * const displayed = optimistic ?? actual + * + * Instead: const [displayed, setOptimistic] = useOptimisticValue(actual) + * + * Uses render-time state adjustment (React-approved) instead of useEffect. + */ +export function useOptimisticValue( + actual: T, + isEqual: (a: T, b: T) => boolean = Object.is +): [displayed: T, setOptimistic: Dispatch>] { + const [optimistic, setOptimistic] = useState(null) + + // Clear optimistic when actual catches up — render-time adjustment avoids useEffect + if (optimistic !== null && isEqual(optimistic, actual)) { + setOptimistic(null) + } + + return [optimistic ?? actual, setOptimistic] +} diff --git a/src/contexts/useDevFlags.tsx b/src/contexts/useDevFlags.tsx index e4609ecf8..e9e4e54b2 100644 --- a/src/contexts/useDevFlags.tsx +++ b/src/contexts/useDevFlags.tsx @@ -1,5 +1,4 @@ -import type { ReactElement, ReactNode } from 'react' -import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, type ReactElement, type ReactNode, useCallback, useContext, useMemo, useState } from 'react' export type HeaderDisplayMode = 'collapsible' | 'full' | 'minimal' | 'sticky-name' @@ -24,35 +23,37 @@ const HEADER_DISPLAY_MODE_STORAGE_KEY = 'dev-header-display-mode' const ENABLE_TOOLBAR = !import.meta.env.PROD || import.meta.env.VITE_ENABLE_DEV_TOOLBAR === 'true' || import.meta.env.MODE !== 'production' -export function DevFlagsProvider({ children }: { children: ReactNode }): ReactElement { - const [headerDisplayMode, setHeaderDisplayMode] = useState('collapsible') +const VALID_MODES: HeaderDisplayMode[] = ['collapsible', 'full', 'minimal', 'sticky-name'] - useEffect(() => { - if (!ENABLE_TOOLBAR) { - return +function readStoredDisplayMode(): HeaderDisplayMode { + if (!ENABLE_TOOLBAR) return 'collapsible' + try { + const stored = window.localStorage.getItem(HEADER_DISPLAY_MODE_STORAGE_KEY) as HeaderDisplayMode | null + if (stored && VALID_MODES.includes(stored)) { + return stored } + } catch { + // no-op + } + return 'collapsible' +} - try { - const stored = window.localStorage.getItem(HEADER_DISPLAY_MODE_STORAGE_KEY) as HeaderDisplayMode | null - if (stored && ['collapsible', 'full', 'minimal', 'sticky-name'].includes(stored)) { - setHeaderDisplayMode(stored) - } - } catch { - // no-op - } - }, []) +function persistDisplayMode(mode: HeaderDisplayMode): void { + if (!ENABLE_TOOLBAR) return + try { + window.localStorage.setItem(HEADER_DISPLAY_MODE_STORAGE_KEY, mode) + } catch { + // no-op + } +} - useEffect(() => { - if (!ENABLE_TOOLBAR) { - return - } +export function DevFlagsProvider({ children }: { children: ReactNode }): ReactElement { + const [headerDisplayMode, setHeaderDisplayModeRaw] = useState(readStoredDisplayMode) - try { - window.localStorage.setItem(HEADER_DISPLAY_MODE_STORAGE_KEY, headerDisplayMode) - } catch { - // no-op - } - }, [headerDisplayMode]) + const setHeaderDisplayMode = useCallback((mode: HeaderDisplayMode) => { + setHeaderDisplayModeRaw(mode) + persistDisplayMode(mode) + }, []) // Legacy support - map new modes to old boolean const headerCompressionEnabled = useMemo(() => headerDisplayMode === 'collapsible', [headerDisplayMode]) @@ -60,7 +61,7 @@ export function DevFlagsProvider({ children }: { children: ReactNode }): ReactEl () => (value: boolean) => { setHeaderDisplayMode(value ? 'collapsible' : 'full') }, - [] + [setHeaderDisplayMode] ) const value = useMemo( From e434b75ca3192d49dacea012ddab94b0544361dd Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:51:58 -0400 Subject: [PATCH 02/15] feat: add Tenderly virtual testnet workflow (#1135) * fix warning not showing correctly (#1117) * fix TVL chart (#1118) * update strategy APY fallback behavior (#1119) * update modals for TGE (#1122) * update modals for TGE * update messaging * update modal language * Fix vault chain selection (#1123) Keep the vault list aligned with the selected chain filter and\nmake chain switching behave consistently across the page.\n\n- apply selected-chain filtering to holdings in both V2 and V3\n vault filters so other-chain rows do not leak into the list\n- reuse a single-chain selection helper for the top selector and\n row chips so switching chains cannot drift into mixed states\n- add focused tests for selected-chain matching and single-chain\n selection behavior * Fix reflected XSS in /api/vault/meta endpoint (#1129) Add strict allowlist validation for chainId and address query parameters before they are interpolated into HTML. chainId must be numeric and address must match the 0x-prefixed 40-char hex pattern, otherwise the request is rejected with a 400. This prevents attackers from breaking out of meta tag attribute contexts to inject arbitrary HTML/scripts. Co-authored-by: Claude Opus 4.6 (1M context) * Add strategy-level KAT rewards APR display (#1128) * Add strategy-level KAT rewards APR display - Schema: make estimated apr/apy optional (Kong hydration may set only apr for strategy-addressed rows), add katRewardsAPR component - Selector: fall back to estimated.apr for katana strategies when estimated.apy is missing, extract katRewardsAPR from estimated type - Strategy UI: show sword emoji indicator when strategy has KAT rewards - Tests: cover katana strategy APR fallback and non-katana isolation * fix: make KAT rewards additive on top of oracle APY with tooltip breakdown - Selector: estimatedAPY now always falls through to oracle.apy for Katana strategies instead of using estimated.apr (which is KAT rewards) - UI: strategy row shows combined APY (oracle + KAT rewards) - UI: hover tooltip breaks down Base APY and KAT Rewards APR - Tooltip follows existing pattern (rounded-lg border surface-secondary) - Tests updated to reflect additive behavior * fix: remove sword emoji from KAT rewards tooltip * style: fix biome formatting * fix kong selector --------- Co-authored-by: JuniorDevBot Co-authored-by: Ross Co-authored-by: rossgalloway <58150151+rossgalloway@users.noreply.github.com> * init tenderly testnets * add tenderly control panel * create tenderly script * refine tenderly vnet script defaults * refine tenderly control panel state * simplify tenderly control panel * harden tenderly vnet script * fix simulate wrapper typing * fix cooldown to use block time * gate tenderly admin routes and validate config * use request IP for tenderly admin access * fix tenderly approval and ens config * fix tenderly loopback and env bootstrap * treat failed tenderly reverts as errors * fix tenderly chain identity mapping * fix tenderly snapshot reset and localhost forks * keep codex agent log local * update gitignore * separate canonical and execution chain lists * fix localhost aliasing and explorer defaults * keep canonical chains in wagmi config * preserve mainnet wagmi transport * fix tenderly execution boundary leaks * Fix locked yvUSD historical PPS and APY normalization (#1133) * update locked APY and PPS timeseries values to reflect actual conditions * review fix * Fix yvUSD historical PPS handling Preserve missing historical PPS values instead of collapsing them to zero.\n\n- Keep weekly and monthly PPS lookbacks nullable in Kong vault APR data\n- Reuse yvUSD chart-derived historical APY in portfolio blending\n- Align portfolio and chart history so partial snapshot PPS data does\n not produce bogus locked APY values * fix tenderly wagmi chain exposure * fix biome import ordering * update env.example --------- Co-authored-by: murderteeth <89237203+murderteeth@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: juniordevbot[bot] <170848020+juniordevbot[bot]@users.noreply.github.com> Co-authored-by: JuniorDevBot --- .env.example | 15 + .gitignore | 1 + api/server.ts | 216 ++++- api/tenderly.helpers.test.ts | 120 +++ api/tenderly.helpers.ts | 191 +++++ api/tenderlyAccess.test.ts | 39 + api/tenderlyAccess.ts | 50 ++ package.json | 1 + scripts/tenderly-vnet.test.ts | 165 ++++ scripts/tenderly-vnet.ts | 638 +++++++++++++++ scripts/tenderly.test.ts | 48 ++ scripts/tenderly.ts | 352 +++++++++ src/App.tsx | 7 +- src/components/pages/portfolio/index.tsx | 2 +- .../components/notifications/Notification.tsx | 8 +- .../vaults/components/widget/WalletPanel.tsx | 2 +- .../widget/deposit/ApprovalOverlay.tsx | 22 +- .../components/widget/migrate/index.tsx | 3 +- .../widget/migrate/useMigrateFlow.ts | 8 +- .../widget/rewards/MerkleRewardRow.tsx | 6 +- .../widget/rewards/StakingRewardRow.tsx | 6 +- .../components/widget/rewards/index.tsx | 2 +- .../widget/shared/TransactionOverlay.tsx | 118 +-- .../components/widget/yvUSD/YvUsdWithdraw.tsx | 85 +- .../vaults/hooks/actions/useDirectDeposit.ts | 6 +- .../vaults/hooks/actions/useDirectStake.ts | 6 +- .../vaults/hooks/actions/useDirectUnstake.ts | 8 +- .../vaults/hooks/actions/useDirectWithdraw.ts | 10 +- .../hooks/actions/useYvUsdLockedZapDeposit.ts | 7 +- .../actions/useYvUsdLockedZapWithdraw.ts | 7 +- .../hooks/rewards/useClaimMerkleRewards.ts | 2 +- .../hooks/rewards/useClaimStakingRewards.ts | 2 +- .../vaults/hooks/rewards/useStakingRewards.ts | 11 +- .../vaults/hooks/solvers/useSolverEnso.ts | 6 +- .../pages/vaults/hooks/useEnsoOrder.ts | 33 +- .../pages/vaults/hooks/useTokenAllowance.ts | 2 +- .../pages/vaults/hooks/useTokens.ts | 24 +- .../pages/vaults/hooks/useVaultUserData.ts | 16 +- src/components/pages/vaults/types/index.ts | 28 +- src/components/shared/components/Header.tsx | 48 ++ .../components/TenderlyControlPanel.tsx | 743 ++++++++++++++++++ .../contexts/useNotificationsActions.tsx | 1 + .../shared/contexts/useTenderlyPanel.tsx | 514 ++++++++++++ src/components/shared/contexts/useWeb3.tsx | 5 +- src/components/shared/hooks/useAppWagmi.ts | 205 +++++ .../shared/hooks/useBalances.multichains.ts | 22 +- .../shared/hooks/useBalancesCombined.ts | 16 +- .../shared/hooks/useBalancesQueries.ts | 4 +- .../shared/hooks/useBalancesQuery.test.ts | 30 + .../shared/hooks/useBalancesQuery.ts | 33 +- .../shared/hooks/useBalancesRouting.test.ts | 15 + .../shared/hooks/useBalancesWithQuery.ts | 4 +- src/components/shared/hooks/useChainID.tsx | 8 +- .../shared/hooks/useChainTimestamp.ts | 14 +- src/components/shared/hooks/useChains.tsx | 12 +- .../shared/hooks/useFetchYearnVaults.ts | 4 +- .../hooks/useStakingAssetConversions.ts | 15 +- .../shared/hooks/useSupportedChains.ts | 14 +- .../hooks/useTransactionStatusPoller.ts | 12 +- src/components/shared/types/index.ts | 1 + src/components/shared/types/notifications.ts | 2 + src/components/shared/types/tenderly.ts | 83 ++ src/components/shared/utils/constants.tsx | 6 +- src/components/shared/utils/index.ts | 1 + .../shared/utils/tenderlyPanel.test.ts | 333 ++++++++ src/components/shared/utils/tenderlyPanel.ts | 449 +++++++++++ src/components/shared/utils/wagmi/actions.ts | 38 +- src/components/shared/utils/wagmi/networks.ts | 27 - src/components/shared/utils/wagmi/provider.ts | 28 +- .../shared/utils/wagmi/utils.test.ts | 23 + src/components/shared/utils/wagmi/utils.ts | 167 ++-- src/config/chainDefinitions.ts | 32 + src/config/supportedChains.test.ts | 22 + src/config/supportedChains.ts | 13 +- src/config/tenderly.test.ts | 119 +++ src/config/tenderly.ts | 403 ++++++++++ src/config/wagmi.ts | 42 +- src/config/wagmiChains.test.ts | 47 ++ src/config/wagmiChains.ts | 19 + src/config/wagmiTransports.test.ts | 37 + src/config/wagmiTransports.ts | 41 + src/context/ChainsProvider.tsx | 36 +- src/context/chainsContext.ts | 2 + 83 files changed, 5472 insertions(+), 491 deletions(-) create mode 100644 api/tenderly.helpers.test.ts create mode 100644 api/tenderly.helpers.ts create mode 100644 api/tenderlyAccess.test.ts create mode 100644 api/tenderlyAccess.ts create mode 100644 scripts/tenderly-vnet.test.ts create mode 100644 scripts/tenderly-vnet.ts create mode 100644 scripts/tenderly.test.ts create mode 100644 scripts/tenderly.ts create mode 100644 src/components/shared/components/TenderlyControlPanel.tsx create mode 100644 src/components/shared/contexts/useTenderlyPanel.tsx create mode 100644 src/components/shared/hooks/useAppWagmi.ts create mode 100644 src/components/shared/hooks/useBalancesQuery.test.ts create mode 100644 src/components/shared/types/tenderly.ts create mode 100644 src/components/shared/utils/tenderlyPanel.test.ts create mode 100644 src/components/shared/utils/tenderlyPanel.ts create mode 100644 src/components/shared/utils/wagmi/utils.test.ts create mode 100644 src/config/chainDefinitions.ts create mode 100644 src/config/supportedChains.test.ts create mode 100644 src/config/tenderly.test.ts create mode 100644 src/config/tenderly.ts create mode 100644 src/config/wagmiChains.test.ts create mode 100644 src/config/wagmiChains.ts create mode 100644 src/config/wagmiTransports.test.ts create mode 100644 src/config/wagmiTransports.ts diff --git a/.env.example b/.env.example index a457d5432..b364548b3 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,21 @@ VITE_RPC_URI_FOR_8453= VITE_RPC_URI_FOR_42161= VITE_RPC_URI_FOR_747474= +# Tenderly Virtual TestNet mode: opt-in execution-chain override layer. +# Keep the app canonical on chain IDs like 1/10/8453 and map execution only through these vars. +# Repeat the *_FOR_ pattern for each canonical chain you enable. +VITE_TENDERLY_MODE= +VITE_TENDERLY_CHAIN_ID_FOR_1= +VITE_TENDERLY_RPC_URI_FOR_1= +# Server-only Admin RPC for local scripts and cheatcodes. Keep this private and out of client-exposed VITE_ vars. +TENDERLY_ADMIN_RPC_URI_FOR_1= +VITE_TENDERLY_EXPLORER_URI_FOR_1= + +PERSONAL_TENDERLY_API_KEY= +PERSONAL_ACCOUNT_SLUG= +PERSONAL_PROJECT_SLUG= +PERSONAL_TENDERLY_RPC_NAME= + VITE_ALCHEMY_KEY= VITE_INFURA_PROJECT_ID= VITE_PARTNER_ID_ADDRESS= diff --git a/.gitignore b/.gitignore index 7d7433988..abbe96a3e 100755 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ yearn.fi-worktree-* # codex settings .codex +.codex/* diff --git a/api/server.ts b/api/server.ts index edd9f459a..0c622ba20 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,10 +1,40 @@ import { serve } from 'bun' +import type { + TTenderlyFundRequest, + TTenderlyIncreaseTimeRequest, + TTenderlyRevertRequest, + TTenderlySnapshotRequest +} from '../src/components/shared/types/tenderly' +import { + buildTenderlyPanelStatus, + buildTenderlyRevertResponse, + buildTenderlySnapshotRecord, + requireTenderlyServerChain, + resolveTenderlyFundRpcRequest +} from './tenderly.helpers' +import { buildTenderlyAdminAccessDeniedResponse } from './tenderlyAccess' const ENSO_API_BASE = 'https://api.enso.finance' const YVUSD_APR_SERVICE_API = ( process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs' ).replace(/\/$/, '') +type TTenderlyJsonRpcSuccess = { + id: string | number | null + jsonrpc: '2.0' + result: unknown +} + +type TTenderlyJsonRpcError = { + id: string | number | null + jsonrpc: '2.0' + error: { + code: number + message: string + data?: unknown + } +} + async function handleYvUsdAprs(req: Request): Promise { if (req.method !== 'GET') { return Response.json({ error: 'Method not allowed' }, { status: 405 }) @@ -43,6 +73,154 @@ async function handleYvUsdAprs(req: Request): Promise { } } +async function parseJsonBody(req: Request): Promise { + try { + return (await req.json()) as T + } catch (_error) { + throw new Error('Invalid JSON body') + } +} + +async function callTenderlyAdminRpc(canonicalChainId: number, method: string, params: unknown[]): Promise { + const configuredChain = requireTenderlyServerChain(process.env, canonicalChainId) + const response = await fetch(configuredChain.adminRpcUri as string, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + const details = await response.text() + throw new Error(`Tenderly RPC request failed with status ${response.status}: ${details}`) + } + + const payload = (await response.json()) as TTenderlyJsonRpcSuccess | TTenderlyJsonRpcError + if ('error' in payload) { + throw new Error(`${payload.error.message} (code ${payload.error.code})`) + } + + return payload.result +} + +function handleTenderlyStatus(req: Request): Response { + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + return Response.json(buildTenderlyPanelStatus(process.env)) + } catch (error) { + console.error('Error building Tenderly status:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to build Tenderly status' }, + { status: 500 } + ) + } +} + +async function handleTenderlySnapshot(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const configuredChain = requireTenderlyServerChain(process.env, body.canonicalChainId) + const snapshotId = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_snapshot', []) + const snapshotRecord = buildTenderlySnapshotRecord({ + canonicalChainId: body.canonicalChainId, + executionChainId: configuredChain.executionChainId, + snapshotId: String(snapshotId), + label: body.label, + isBaseline: body.isBaseline + }) + + return Response.json(snapshotRecord) + } catch (error) { + console.error('Error creating Tenderly snapshot:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to create Tenderly snapshot' }, + { status: 400 } + ) + } +} + +async function handleTenderlyRevert(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const result = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_revert', [body.snapshotId]) + + return Response.json(buildTenderlyRevertResponse(result, body.snapshotId)) + } catch (error) { + console.error('Error reverting Tenderly snapshot:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to revert Tenderly snapshot' }, + { status: 400 } + ) + } +} + +async function handleTenderlyIncreaseTime(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + if (!Number.isInteger(body.seconds) || body.seconds <= 0) { + throw new Error('seconds must be a positive integer') + } + + const timeResult = await callTenderlyAdminRpc(body.canonicalChainId, 'evm_increaseTime', [ + `0x${BigInt(body.seconds).toString(16)}` + ]) + const mineResult = body.mineBlock ? await callTenderlyAdminRpc(body.canonicalChainId, 'evm_mine', []) : undefined + + return Response.json({ + timeResult, + mineResult + }) + } catch (error) { + console.error('Error increasing Tenderly time:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to increase Tenderly time' }, + { status: 400 } + ) + } +} + +async function handleTenderlyFund(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + try { + const body = await parseJsonBody(req) + const { method, params } = resolveTenderlyFundRpcRequest(body) + const result = await callTenderlyAdminRpc(body.canonicalChainId, method, params) + + return Response.json({ + method, + result + }) + } catch (error) { + console.error('Error funding Tenderly wallet:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to fund wallet on Tenderly' }, + { status: 400 } + ) + } +} + function handleEnsoStatus(): Response { const apiKey = process.env.ENSO_API_KEY return Response.json({ configured: !!apiKey }) @@ -159,7 +337,7 @@ async function handleEnsoBalances(req: Request): Promise { } serve({ - async fetch(req) { + async fetch(req, server) { const url = new URL(req.url) if (url.pathname === '/api/enso/status') { @@ -178,6 +356,42 @@ serve({ return handleYvUsdAprs(req) } + if (url.pathname === '/api/tenderly/status') { + return handleTenderlyStatus(req) + } + + if (url.pathname === '/api/tenderly/snapshot') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlySnapshot(req) + } + + if (url.pathname === '/api/tenderly/revert') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyRevert(req) + } + + if (url.pathname === '/api/tenderly/increase-time') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyIncreaseTime(req) + } + + if (url.pathname === '/api/tenderly/fund') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return accessDeniedResponse + } + return handleTenderlyFund(req) + } + return new Response('Not found', { status: 404 }) }, port: 3001 diff --git a/api/tenderly.helpers.test.ts b/api/tenderly.helpers.test.ts new file mode 100644 index 000000000..1c180b6a6 --- /dev/null +++ b/api/tenderly.helpers.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { TTenderlyFundRequest } from '../src/components/shared/types/tenderly' +import { + buildTenderlyPanelStatus, + buildTenderlyRevertResponse, + buildTenderlySnapshotRecord, + parseTenderlyServerChains, + resolveTenderlyFundRpcRequest +} from './tenderly.helpers' + +describe('parseTenderlyServerChains', () => { + it('parses configured Tenderly chains from environment variables', () => { + expect( + parseTenderlyServerChains({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '694201', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc', + TENDERLY_ADMIN_RPC_URI_FOR_1: 'https://admin.rpc' + }) + ).toEqual([ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 694201, + rpcUri: 'https://public.rpc', + adminRpcUri: 'https://admin.rpc' + } + ]) + }) +}) + +describe('buildTenderlyPanelStatus', () => { + it('reports admin availability per configured chain', () => { + expect( + buildTenderlyPanelStatus({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '694201', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://public.rpc' + }) + ).toEqual({ + isTenderlyModeEnabled: true, + configuredChains: [ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 694201, + hasAdminRpc: false + } + ] + }) + }) +}) + +describe('buildTenderlySnapshotRecord', () => { + it('creates a normalized baseline snapshot record', () => { + const record = buildTenderlySnapshotRecord({ + canonicalChainId: 1, + executionChainId: 694201, + snapshotId: '0x1', + isBaseline: true + }) + + expect(record.kind).toBe('baseline') + expect(record.lastKnownStatus).toBe('valid') + expect(record.label.startsWith('Baseline ')).toBe(true) + }) +}) + +describe('buildTenderlyRevertResponse', () => { + it('returns a success payload when Tenderly restores the snapshot', () => { + expect(buildTenderlyRevertResponse(true, '0x1')).toEqual({ + success: true, + revertedSnapshotId: '0x1' + }) + }) + + it('throws when Tenderly rejects the revert', () => { + expect(() => buildTenderlyRevertResponse(false, '0xdead')).toThrow('Tenderly rejected revert for snapshot 0xdead') + }) +}) + +describe('resolveTenderlyFundRpcRequest', () => { + it('builds native balance funding calls', () => { + const request: TTenderlyFundRequest = { + canonicalChainId: 1, + walletAddress: '0x1111111111111111111111111111111111111111', + assetKind: 'native', + symbol: 'ETH', + decimals: 18, + amount: '1.5', + mode: 'add' + } + + expect(resolveTenderlyFundRpcRequest(request)).toEqual({ + method: 'tenderly_addBalance', + params: [['0x1111111111111111111111111111111111111111'], '0x14d1120d7b160000'] + }) + }) + + it('builds ERC-20 funding calls', () => { + const request: TTenderlyFundRequest = { + canonicalChainId: 1, + walletAddress: '0x1111111111111111111111111111111111111111', + assetKind: 'erc20', + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + amount: '50000' + } + + expect(resolveTenderlyFundRpcRequest(request)).toEqual({ + method: 'tenderly_setErc20Balance', + params: [ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0x1111111111111111111111111111111111111111', + '0xba43b7400' + ] + }) + }) +}) diff --git a/api/tenderly.helpers.ts b/api/tenderly.helpers.ts new file mode 100644 index 000000000..5b22c0077 --- /dev/null +++ b/api/tenderly.helpers.ts @@ -0,0 +1,191 @@ +import { getAddress } from 'viem' +import type { + TTenderlyFundRequest, + TTenderlyPanelStatus, + TTenderlyRevertResponse, + TTenderlySnapshotRecord +} from '../src/components/shared/types/tenderly' +import { canonicalChains } from '../src/config/chainDefinitions' + +type TTenderlyServerEnv = Record + +export type TTenderlyServerChainConfig = { + canonicalChainId: number + canonicalChainName: string + executionChainId: number + rpcUri: string + adminRpcUri?: string +} + +function readEnvString(value: string | undefined): string { + return typeof value === 'string' ? value.trim() : '' +} + +function isTruthyEnvValue(value: string | undefined): boolean { + return ['1', 'on', 'true', 'yes'].includes(readEnvString(value).toLowerCase()) +} + +export function parseTenderlyServerChains(env: TTenderlyServerEnv): TTenderlyServerChainConfig[] { + if (!isTruthyEnvValue(env.VITE_TENDERLY_MODE)) { + return [] + } + + return canonicalChains.reduce((accumulator, chain) => { + const rawExecutionChainId = readEnvString(env[`VITE_TENDERLY_CHAIN_ID_FOR_${chain.id}`]) + const rawRpcUri = readEnvString(env[`VITE_TENDERLY_RPC_URI_FOR_${chain.id}`]) + const rawAdminRpcUri = readEnvString(env[`TENDERLY_ADMIN_RPC_URI_FOR_${chain.id}`]) + const hasAnyConfig = Boolean(rawExecutionChainId || rawRpcUri || rawAdminRpcUri) + + if (!hasAnyConfig) { + return accumulator + } + + if (!rawExecutionChainId || !rawRpcUri) { + throw new Error( + `Tenderly chain ${chain.id} requires both VITE_TENDERLY_CHAIN_ID_FOR_${chain.id} and VITE_TENDERLY_RPC_URI_FOR_${chain.id}` + ) + } + + const executionChainId = Number(rawExecutionChainId) + if (!Number.isInteger(executionChainId) || executionChainId <= 0) { + throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) + } + + accumulator.push({ + canonicalChainId: chain.id, + canonicalChainName: chain.name, + executionChainId, + rpcUri: rawRpcUri, + adminRpcUri: rawAdminRpcUri || undefined + }) + + return accumulator + }, []) +} + +export function buildTenderlyPanelStatus(env: TTenderlyServerEnv): TTenderlyPanelStatus { + return { + isTenderlyModeEnabled: isTruthyEnvValue(env.VITE_TENDERLY_MODE), + configuredChains: parseTenderlyServerChains(env).map((chain) => ({ + canonicalChainId: chain.canonicalChainId, + canonicalChainName: chain.canonicalChainName, + executionChainId: chain.executionChainId, + hasAdminRpc: Boolean(chain.adminRpcUri) + })) + } +} + +export function requireTenderlyServerChain( + env: TTenderlyServerEnv, + canonicalChainId: number +): TTenderlyServerChainConfig { + const configuredChain = parseTenderlyServerChains(env).find((chain) => chain.canonicalChainId === canonicalChainId) + + if (!configuredChain) { + throw new Error(`Tenderly chain ${canonicalChainId} is not configured`) + } + + if (!configuredChain.adminRpcUri) { + throw new Error(`Missing TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}`) + } + + return configuredChain +} + +export function parseDecimalAmount(value: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals value: ${decimals}`) + } + + const normalizedValue = value.trim() + if (normalizedValue.length === 0) { + throw new Error('Amount is required') + } + + if (normalizedValue.startsWith('-')) { + throw new Error('Negative amounts are not supported') + } + + if (!/^\d+(\.\d+)?$/.test(normalizedValue)) { + throw new Error(`Invalid decimal amount: ${value}`) + } + + const [wholePartRaw, fractionalPartRaw = ''] = normalizedValue.split('.') + if (fractionalPartRaw.length > decimals) { + throw new Error(`Amount ${value} exceeds ${decimals} decimals`) + } + + return BigInt(wholePartRaw) * 10n ** BigInt(decimals) + BigInt(fractionalPartRaw.padEnd(decimals, '0') || '0') +} + +export function toHexQuantity(value: bigint): `0x${string}` { + if (value < 0n) { + throw new Error('Negative quantities are not supported') + } + + return `0x${value.toString(16)}` as const +} + +export function buildDefaultSnapshotLabel(kind: TTenderlySnapshotRecord['kind']): string { + const prefix = kind === 'baseline' ? 'Baseline' : 'Snapshot' + return `${prefix} ${new Date() + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ' UTC')}` +} + +export function buildTenderlySnapshotRecord(params: { + canonicalChainId: number + executionChainId: number + snapshotId: string + label?: string + isBaseline?: boolean +}): TTenderlySnapshotRecord { + const kind = params.isBaseline ? 'baseline' : 'snapshot' + + return { + snapshotId: params.snapshotId, + canonicalChainId: params.canonicalChainId, + executionChainId: params.executionChainId, + label: params.label?.trim() || buildDefaultSnapshotLabel(kind), + createdAt: new Date().toISOString(), + kind, + lastKnownStatus: 'valid' + } +} + +export function buildTenderlyRevertResponse(result: unknown, snapshotId: string): TTenderlyRevertResponse { + if (result !== true) { + throw new Error(`Tenderly rejected revert for snapshot ${snapshotId}`) + } + + return { + success: true, + revertedSnapshotId: snapshotId + } +} + +export function resolveTenderlyFundRpcRequest(body: TTenderlyFundRequest): { + method: string + params: unknown[] +} { + const walletAddress = getAddress(body.walletAddress) + const amount = parseDecimalAmount(body.amount, body.decimals) + + if (body.assetKind === 'native') { + const mode = body.mode === 'set' ? 'set' : 'add' + return { + method: mode === 'set' ? 'tenderly_setBalance' : 'tenderly_addBalance', + params: [[walletAddress], toHexQuantity(amount)] + } + } + + if (!body.tokenAddress) { + throw new Error('tokenAddress is required for ERC-20 funding') + } + + return { + method: 'tenderly_setErc20Balance', + params: [getAddress(body.tokenAddress), walletAddress, toHexQuantity(amount)] + } +} diff --git a/api/tenderlyAccess.test.ts b/api/tenderlyAccess.test.ts new file mode 100644 index 000000000..2151e5996 --- /dev/null +++ b/api/tenderlyAccess.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + buildTenderlyAdminAccessDeniedResponse, + isLoopbackAddress, + isTenderlyAdminRequestAllowed +} from './tenderlyAccess' + +describe('isLoopbackAddress', () => { + it('accepts loopback addresses', () => { + expect(isLoopbackAddress('localhost')).toBe(true) + expect(isLoopbackAddress('127.0.0.1')).toBe(true) + expect(isLoopbackAddress('127.0.0.2')).toBe(true) + expect(isLoopbackAddress('::1')).toBe(true) + expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true) + }) + + it('rejects non-loopback addresses', () => { + expect(isLoopbackAddress('10.0.0.8')).toBe(false) + expect(isLoopbackAddress(undefined)).toBe(false) + }) +}) + +describe('isTenderlyAdminRequestAllowed', () => { + it('allows loopback client addresses', () => { + expect(isTenderlyAdminRequestAllowed('127.0.0.1')).toBe(true) + expect(isTenderlyAdminRequestAllowed('::1')).toBe(true) + expect(isTenderlyAdminRequestAllowed('::ffff:127.0.0.1')).toBe(true) + }) + + it('rejects remote client addresses', async () => { + expect(isTenderlyAdminRequestAllowed('10.0.0.8')).toBe(false) + + const response = buildTenderlyAdminAccessDeniedResponse('10.0.0.8') + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: 'Tenderly admin routes are only available from localhost' + }) + }) +}) diff --git a/api/tenderlyAccess.ts b/api/tenderlyAccess.ts new file mode 100644 index 000000000..949848e10 --- /dev/null +++ b/api/tenderlyAccess.ts @@ -0,0 +1,50 @@ +const LOOPBACK_ADDRESSES = new Set(['localhost', '::1']) + +function isLoopbackIpv4(address: string): boolean { + const octets = address.split('.') + if (octets.length !== 4) { + return false + } + + return ( + octets[0] === '127' && + octets.every((octet) => { + if (!/^\d+$/.test(octet)) { + return false + } + + const value = Number(octet) + return value >= 0 && value <= 255 + }) + ) +} + +export function isLoopbackAddress(address: string | null | undefined): boolean { + if (typeof address !== 'string') { + return false + } + + if (LOOPBACK_ADDRESSES.has(address) || isLoopbackIpv4(address)) { + return true + } + + if (address.startsWith('::ffff:')) { + return isLoopbackIpv4(address.slice('::ffff:'.length)) + } + + return false +} + +export function isTenderlyAdminRequestAllowed(requestIpAddress: string | null | undefined): boolean { + return isLoopbackAddress(requestIpAddress) +} + +export function buildTenderlyAdminAccessDeniedResponse( + requestIpAddress: string | null | undefined +): Response | undefined { + if (isTenderlyAdminRequestAllowed(requestIpAddress)) { + return undefined + } + + return Response.json({ error: 'Tenderly admin routes are only available from localhost' }, { status: 403 }) +} diff --git a/package.json b/package.json index f84b37ca6..be6083ea4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev:client": "vite", "dev:server": "bun --watch api/server.ts", "dev:ts": "tsc --watch", + "tenderly": "bun scripts/tenderly.ts", "build": "tsc && vite build", "preview": "concurrently \"bun run preview:server\" \"bun run preview:client\"", "preview:client": "vite preview", diff --git a/scripts/tenderly-vnet.test.ts b/scripts/tenderly-vnet.test.ts new file mode 100644 index 000000000..faad488f3 --- /dev/null +++ b/scripts/tenderly-vnet.test.ts @@ -0,0 +1,165 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildSanitizedVnetJson, + buildTenderlyApiErrorMessage, + buildTenderlyEnvFragment, + buildVnetConsoleSummary, + resolveExplorerUriFromResponse, + sanitizeConsoleText, + validateWritableOutputPath, + writeOutputFile +} from './tenderly-vnet' + +const connectionDetails = { + adminRpc: 'https://admin.rpc/secret-path', + publicRpc: 'https://dynamic.public.rpc/ephemeral-path', + predictablePublicRpc: 'https://predictable.public.rpc/stable-path', + explorerUri: 'https://explorer.tenderly.public/virtual-mainnet' +} + +const response = { + slug: 'vnet-123', + display_name: 'Personal VNet 123', + id: 'vnet-id-123', + rpcs: [ + { name: 'Admin RPC', url: connectionDetails.adminRpc }, + { name: 'Public RPC', url: connectionDetails.publicRpc } + ] +} + +describe('tenderly-vnet console safety', () => { + it('redacts URLs in console text', () => { + expect(sanitizeConsoleText('failed at https://admin.rpc/secret-path')).toBe('failed at [redacted-url]') + }) + + it('builds an env fragment with the preferred public RPC without printing it elsewhere', () => { + const envFragment = buildTenderlyEnvFragment({ + canonicalChainId: 1, + executionChainId: 694201, + details: connectionDetails + }) + + expect(envFragment).toContain('VITE_TENDERLY_MODE=true') + expect(envFragment).toContain('VITE_TENDERLY_CHAIN_ID_FOR_1=694201') + expect(envFragment).toContain('VITE_TENDERLY_RPC_URI_FOR_1=https://predictable.public.rpc/stable-path') + expect(envFragment).toContain('TENDERLY_ADMIN_RPC_URI_FOR_1=https://admin.rpc/secret-path') + expect(envFragment).toContain('VITE_TENDERLY_EXPLORER_URI_FOR_1=https://explorer.tenderly.public/virtual-mainnet') + }) + + it('extracts explorer urls from explorer-specific response fields without mistaking public rpc urls for explorers', () => { + expect( + resolveExplorerUriFromResponse({ + public_rpc_url: 'https://rpc.tenderly.example/ignored', + explorer_page: { + public_url: 'https://explorer.tenderly.public/virtual-mainnet' + } + }) + ).toBe('https://explorer.tenderly.public/virtual-mainnet') + }) + + it('omits sensitive RPC values from the default console summary', () => { + const summary = buildVnetConsoleSummary({ + profile: 'personal', + requestedSlug: 'vnet-123', + displayName: 'Personal VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails + }).join('\n') + + expect(summary).toContain('Created Tenderly Virtual TestNet') + expect(summary).toContain('Sensitive RPC values were returned but not printed.') + expect(summary).not.toContain(connectionDetails.adminRpc) + expect(summary).not.toContain(connectionDetails.publicRpc) + expect(summary).not.toContain(connectionDetails.predictablePublicRpc) + }) + + it('includes only file paths when sensitive artifacts are written to disk', () => { + const summary = buildVnetConsoleSummary({ + profile: 'webops', + requestedSlug: 'vnet-123', + displayName: 'Webops VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails, + envFilePath: '/tmp/tenderly-vnet.env', + responseFilePath: '/tmp/tenderly-vnet.json' + }).join('\n') + + expect(summary).toContain('/tmp/tenderly-vnet.env') + expect(summary).toContain('/tmp/tenderly-vnet.json') + expect(summary).not.toContain('Sensitive RPC values were returned but not printed.') + expect(summary).not.toContain(connectionDetails.adminRpc) + }) + + it('prints a sanitized JSON summary without RPC URLs', () => { + const sanitized = buildSanitizedVnetJson({ + profile: 'personal', + requestedSlug: 'vnet-123', + displayName: 'Personal VNet 123', + chainId: 694201, + networkId: 1, + response, + details: connectionDetails + }) + + const serialized = JSON.stringify(sanitized) + + expect(serialized).toContain('"has_admin_rpc":true') + expect(serialized).toContain('"has_public_rpc":true') + expect(serialized).not.toContain('"rpcs"') + expect(serialized).not.toContain(connectionDetails.adminRpc) + expect(serialized).not.toContain(connectionDetails.publicRpc) + expect(serialized).not.toContain(connectionDetails.predictablePublicRpc) + }) + + it('sanitizes API error messages instead of echoing raw response data', () => { + const message = buildTenderlyApiErrorMessage({ + status: 404, + parsedBody: { + message: 'Request failed for https://admin.rpc/secret-path', + details: 'hidden' + } + }) + + expect(message).toContain('Tenderly API request failed (404)') + expect(message).toContain('[redacted-url]') + expect(message).toContain('Hint: check --account and --project slugs.') + expect(message).not.toContain(connectionDetails.adminRpc) + expect(message).not.toContain('Response:') + }) + + it('fails local output validation before writing to an unwritable location', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tenderly-vnet-')) + const lockedDir = join(tempDir, 'locked') + mkdirSync(lockedDir, { recursive: true }) + chmodSync(lockedDir, 0o500) + + try { + expect(() => validateWritableOutputPath(join(lockedDir, 'vnet.env'))).toThrow('Cannot write output file') + } finally { + chmodSync(lockedDir, 0o700) + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('writes secret output files with 0600 permissions', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tenderly-vnet-')) + const outputFile = join(tempDir, 'vnet.env') + + try { + validateWritableOutputPath(outputFile) + writeOutputFile(outputFile, 'secret=value\n') + + expect(readFileSync(outputFile, 'utf8')).toBe('secret=value\n') + expect(statSync(outputFile).mode & 0o777).toBe(0o600) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/tenderly-vnet.ts b/scripts/tenderly-vnet.ts new file mode 100644 index 000000000..1a092db96 --- /dev/null +++ b/scripts/tenderly-vnet.ts @@ -0,0 +1,638 @@ +/// + +import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +type TParsedCliArgs = { + flags: Record + positionals: string[] +} + +type TTenderlyProfile = 'webops' | 'personal' + +type TResolvedTenderlyCredentials = { + apiKey: string + accountSlug: string + projectSlug: string + profile: TTenderlyProfile +} + +type TTenderlyVnetResponse = { + slug?: string + display_name?: string + chain_id?: number + network_id?: number + id?: string + rpcs?: Array<{ + name: string + url: string + }> + [key: string]: unknown +} + +type TTenderlyVnetConnectionDetails = { + adminRpc?: string + publicRpc?: string + predictablePublicRpc?: string + explorerUri?: string +} + +type TCreateVnetPayload = { + slug: string + display_name: string + fork_config: { + network_id: number + block_number: string + } + virtual_network_config: { + chain_config: { + chain_id: number + } + } + sync_state_config: { + enabled: boolean + commitment_level: 'latest' + } + explorer_page_config: { + enabled: boolean + verification_visibility: 'bytecode' + } + rpc_config?: { + rpc_name: string + } +} + +const DEFAULT_ACCOUNT_SLUG = 'me' +const DEFAULT_NETWORK_ID = 1 +const DEFAULT_CHAIN_ID_PREFIX = '69420' +const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' +const REDACTED_URL = '[redacted-url]' + +const HELP_TEXT = `Tenderly Virtual TestNet bootstrap + +Usage: + bun run scripts/tenderly-vnet.ts [options] + +Options: + --profile Credential profile: webops or personal (default: webops) + --account Tenderly account slug (defaults to TENDERLY_ACCOUNT_SLUG, then me) + --project Tenderly project slug (defaults from selected profile env vars) + --api-key Tenderly API key override + --api-key-env Env var name to read the API key from + --account-env Env var name to read the account slug from + --project-env Env var name to read the project slug from + --slug VNet slug (default: vnet-) + --display-name VNet display name (default: Webops VNet) + --network-id Parent network id (default: 1) + --block-number Fork block number or latest (default: latest) + --chain-id Execution chain id (default: 69420) + --rpc-name Stable public RPC name for predictable endpoint reuse + --enable-sync Enable state sync (default: false) + --enable-explorer Enable public explorer (default: false) + --json Print a sanitized JSON summary + --write-env Write the generated Tenderly env fragment to a local file + --write-response Write the raw Tenderly API response JSON to a local file + --help Show this help text + +Profiles: + webops -> WEBOPS_TENDERLY_API_KEY, TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME + personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME + +Sensitive RPC values are never printed to stdout. Use --write-env or --write-response when you need them locally. +Explicit flags always win over profile defaults. +` + +function parseProfile(value: string | undefined): TTenderlyProfile { + if (!value || value.trim().length === 0) { + return 'webops' + } + + const normalized = value.trim().toLowerCase() + if (normalized === 'webops' || normalized === 'personal') { + return normalized + } + + throw new Error(`Unsupported profile: ${value}. Expected "webops" or "personal".`) +} + +function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const recurse = (index: number, acc: TParsedCliArgs): TParsedCliArgs => { + if (index >= argv.length) return acc + + const token = argv[index] + if (!token.startsWith('--')) { + return recurse(index + 1, { ...acc, positionals: [...acc.positionals, token] }) + } + + const key = token.slice(2) + const nextValue = argv[index + 1] + const maybeValue = nextValue && !nextValue.startsWith('--') ? nextValue : 'true' + const nextIndex = maybeValue === 'true' ? index + 1 : index + 2 + + return recurse(nextIndex, { + ...acc, + flags: { + ...acc.flags, + [key]: maybeValue + } + }) + } + + return recurse(0, { flags: {}, positionals: [] }) +} + +function readEnvFile(path = '.env'): Record { + if (!existsSync(path)) return {} + + const envLines = readFileSync(path, 'utf8').split('\n') + + return envLines + .map((rawLine) => rawLine.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const separatorIndex = line.indexOf('=') + if (separatorIndex < 0) return null + + const key = line.slice(0, separatorIndex).trim() + if (!key) return null + + const rawValue = line.slice(separatorIndex + 1).trim() + if (!rawValue) return [key, ''] + + const value = rawValue.replace(/^['"]|['"]$/g, '') + return [key, value] + }) + .filter((entry): entry is [string, string] => Boolean(entry)) + .reduce( + (acc, [key, value]) => { + acc[key] = value + return acc + }, + {} as Record + ) +} + +function requireString(value: string | undefined, label: string): string { + if (!value || value.trim().length === 0) { + throw new Error(`Missing required value: ${label}`) + } + return value +} + +function parseOptionalInteger(value: string | undefined, label: string): number | undefined { + if (!value) return undefined + + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid ${label}: ${value}`) + } + return parsed +} + +function parseBooleanFlag(flags: Record, key: string, defaultValue = false): boolean { + if (!(key in flags)) return defaultValue + return String(flags[key]).toLowerCase() !== 'false' +} + +function getArg(flags: Record, ...keys: string[]): string | undefined { + return keys.map((key) => flags[key]?.trim()).find((value) => Boolean(value)) +} + +function resolveDefaultChainId(networkId: number): number { + const derivedChainId = Number(`${DEFAULT_CHAIN_ID_PREFIX}${networkId}`) + if (!Number.isSafeInteger(derivedChainId) || derivedChainId <= 0) { + throw new Error(`Unable to derive default chain id for network ${networkId}`) + } + return derivedChainId +} + +function getEnvValue(env: Record, key?: string): string | undefined { + if (!key) return undefined + return env[key]?.trim() +} + +export function sanitizeConsoleText(value: string): string { + return value.replace(/https?:\/\/\S+/gi, REDACTED_URL) +} + +function resolveTenderlyCredentials( + flags: Record, + env: Record +): TResolvedTenderlyCredentials { + const profile = parseProfile(getArg(flags, 'profile')) + const profileDefaults = + profile === 'personal' + ? { + apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', + accountEnv: 'PERSONAL_ACCOUNT_SLUG', + projectEnv: 'PERSONAL_PROJECT_SLUG' + } + : { + apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', + accountEnv: 'TENDERLY_ACCOUNT_SLUG', + projectEnv: 'TENDERLY_PROJECT_SLUG' + } + + const apiKeyEnv = getArg(flags, 'api-key-env') || profileDefaults.apiKeyEnv + const accountEnv = getArg(flags, 'account-env') || profileDefaults.accountEnv + const projectEnv = getArg(flags, 'project-env') || profileDefaults.projectEnv + + const apiKey = + getArg(flags, 'api-key') || + getEnvValue(env, apiKeyEnv) || + env.TENDERLY_ACCESS_KEY?.trim() || + env.TENDERLY_API_KEY?.trim() + + if (!apiKey) { + throw new Error('Missing Tenderly API key. Provide --api-key or configure a supported Tenderly API key env var.') + } + + const accountSlug = + getArg(flags, 'account') || + getEnvValue(env, accountEnv) || + (profile === 'personal' ? env.ACCOUNT_SLUG?.trim() : undefined) || + DEFAULT_ACCOUNT_SLUG + const projectSlug = requireString( + getArg(flags, 'project') || + getEnvValue(env, projectEnv) || + (profile === 'personal' ? env.PROJECT_SLUG?.trim() : undefined), + '--project or a configured Tenderly project env var' + ) + + return { + apiKey, + accountSlug, + projectSlug, + profile + } +} + +function resolveRpcName( + flags: Record, + env: Record, + profile: TTenderlyProfile +): string | undefined { + const profileDefaultEnvKey = profile === 'personal' ? 'PERSONAL_TENDERLY_RPC_NAME' : 'WEBOPS_TENDERLY_RPC_NAME' + return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaultEnvKey) || env.TENDERLY_RPC_NAME?.trim() +} + +function buildPredictablePublicRpcUrl(accountSlug: string, projectSlug: string, rpcName: string): string { + return `https://virtual.rpc.tenderly.co/${encodeURIComponent(accountSlug)}/${encodeURIComponent(projectSlug)}/public/${encodeURIComponent(rpcName)}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isHttpUrl(value: unknown): value is string { + return typeof value === 'string' && /^https?:\/\//i.test(value) +} + +export function resolveExplorerUriFromResponse(response: TTenderlyVnetResponse): string | undefined { + const queue: Array<{ value: unknown; path: string[] }> = [{ value: response, path: [] }] + const visited = new Set() + + while (queue.length > 0) { + const current = queue.shift() + if (!current || !isRecord(current.value) || visited.has(current.value)) { + continue + } + visited.add(current.value) + + for (const [key, value] of Object.entries(current.value)) { + const normalizedKey = key.toLowerCase() + const normalizedPath = [...current.path, normalizedKey] + const isExplorerPath = normalizedPath.some((part) => part.includes('explorer')) + + if (isExplorerPath && isHttpUrl(value)) { + return value + } + + if (Array.isArray(value)) { + queue.push(...value.map((entry) => ({ value: entry, path: normalizedPath }))) + continue + } + + if (isRecord(value)) { + queue.push({ value, path: normalizedPath }) + } + } + } + + return undefined +} + +function getConnectionDetails( + response: TTenderlyVnetResponse, + accountSlug: string, + projectSlug: string, + rpcName: string | undefined +): TTenderlyVnetConnectionDetails { + return { + adminRpc: response.rpcs?.find((rpc) => rpc.name === 'Admin RPC')?.url, + publicRpc: response.rpcs?.find((rpc) => rpc.name === 'Public RPC')?.url, + predictablePublicRpc: rpcName ? buildPredictablePublicRpcUrl(accountSlug, projectSlug, rpcName) : undefined, + explorerUri: resolveExplorerUriFromResponse(response) + } +} + +function selectPublicRpc(details: TTenderlyVnetConnectionDetails): string | undefined { + return details.predictablePublicRpc || details.publicRpc +} + +function resolveOutputPath(value: string | undefined, flagName: string): string { + const trimmedValue = value?.trim() + if (!trimmedValue || trimmedValue === 'true') { + throw new Error(`--${flagName} requires a file path`) + } + + return resolve(process.cwd(), trimmedValue) +} + +export function validateWritableOutputPath(path: string): string { + const outputDir = dirname(path) + + try { + mkdirSync(outputDir, { recursive: true, mode: 0o700 }) + + if (existsSync(path)) { + if (statSync(path).isDirectory()) { + throw new Error(`Output path is a directory: ${path}`) + } + accessSync(path, constants.W_OK) + return path + } + + accessSync(outputDir, constants.W_OK) + return path + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Cannot write output file ${path}: ${message}`) + } +} + +export function writeOutputFile(path: string, contents: string): string { + writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600 }) + chmodSync(path, 0o600) + return path +} + +function resolveRequestedOutputPaths(flags: Record): { + envFilePath?: string + responseFilePath?: string +} { + const envFilePath = + 'write-env' in flags + ? validateWritableOutputPath(resolveOutputPath(getArg(flags, 'write-env'), 'write-env')) + : undefined + const responseFilePath = + 'write-response' in flags + ? validateWritableOutputPath(resolveOutputPath(getArg(flags, 'write-response'), 'write-response')) + : undefined + + return { + envFilePath, + responseFilePath + } +} + +export function buildTenderlyEnvFragment(params: { + canonicalChainId: number + executionChainId: number + details: TTenderlyVnetConnectionDetails +}): string { + const publicRpc = selectPublicRpc(params.details) || '' + + return [ + '# Generated by scripts/tenderly-vnet.ts', + 'VITE_TENDERLY_MODE=true', + `VITE_TENDERLY_CHAIN_ID_FOR_${params.canonicalChainId}=${params.executionChainId}`, + `VITE_TENDERLY_RPC_URI_FOR_${params.canonicalChainId}=${publicRpc}`, + `TENDERLY_ADMIN_RPC_URI_FOR_${params.canonicalChainId}=${params.details.adminRpc || ''}`, + `VITE_TENDERLY_EXPLORER_URI_FOR_${params.canonicalChainId}=${params.details.explorerUri || ''}` + ].join('\n') +} + +export function buildSanitizedVnetJson(params: { + profile: TTenderlyProfile + requestedSlug: string + displayName: string + chainId: number + networkId: number + response: TTenderlyVnetResponse + details: TTenderlyVnetConnectionDetails + envFilePath?: string + responseFilePath?: string +}): Record { + const sanitizedResponse = Object.fromEntries( + Object.entries(params.response).filter(([key]) => key !== 'rpcs') + ) as Record + const hasSensitiveValues = Boolean( + params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc + ) + + return { + ...sanitizedResponse, + profile: params.profile, + slug: params.response.slug || params.requestedSlug, + display_name: params.response.display_name || params.displayName, + chain_id: params.chainId, + network_id: params.networkId, + has_admin_rpc: Boolean(params.details.adminRpc), + has_public_rpc: Boolean(selectPublicRpc(params.details)), + env_file_path: params.envFilePath || null, + response_file_path: params.responseFilePath || null, + note: + hasSensitiveValues && !params.envFilePath && !params.responseFilePath + ? 'Sensitive RPC values were returned but were not printed. Use --write-env or --write-response to persist them locally.' + : undefined + } +} + +export function buildVnetConsoleSummary(params: { + profile: TTenderlyProfile + requestedSlug: string + displayName: string + chainId: number + networkId: number + response: TTenderlyVnetResponse + details: TTenderlyVnetConnectionDetails + envFilePath?: string + responseFilePath?: string +}): string[] { + const hasSensitiveValues = Boolean( + params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc + ) + + return [ + 'Created Tenderly Virtual TestNet', + `profile: ${params.profile}`, + `slug: ${params.response.slug || params.requestedSlug}`, + `display name: ${params.response.display_name || params.displayName}`, + params.envFilePath ? `wrote env fragment: ${params.envFilePath}` : undefined, + params.responseFilePath ? `wrote raw response json: ${params.responseFilePath}` : undefined, + hasSensitiveValues && !params.envFilePath && !params.responseFilePath + ? 'Sensitive RPC values were returned but not printed. Use --write-env or --write-response to persist them locally.' + : undefined, + `chain-id: ${params.chainId}`, + `network-id: ${params.networkId}` + ].filter((line): line is string => Boolean(line)) +} + +function parseResponseBody(responseBody: string): TTenderlyVnetResponse { + try { + return JSON.parse(responseBody) as TTenderlyVnetResponse + } catch { + return {} + } +} + +export function buildTenderlyApiErrorMessage(params: { status: number; parsedBody: TTenderlyVnetResponse }): string { + const record = params.parsedBody as Record + const rawApiMessage = typeof record.message === 'string' ? record.message : undefined + const apiMessage = rawApiMessage ? `: ${sanitizeConsoleText(rawApiMessage)}` : '' + const accountHint = + params.status === 404 + ? '\nHint: check --account and --project slugs. Use your team/org slug in --account for 404 cases, not a project id.' + : '' + + return `Tenderly API request failed (${params.status})${apiMessage}${accountHint}` +} + +async function createVirtualTestNet( + apiKey: string, + accountSlug: string, + projectSlug: string, + payload: TCreateVnetPayload +): Promise { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'content-type': 'application/json', + 'X-Access-Key': apiKey + }, + body: JSON.stringify(payload) + } + ) + + const responseBody = (await response.text()) || '{}' + const parsed = parseResponseBody(responseBody) + + if (!response.ok) { + throw new Error(buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parsed })) + } + + return parsed +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + const { flags } = parsedArgs + + if ('help' in flags || 'h' in flags) { + console.log(HELP_TEXT) + return + } + + const scriptDir = resolve(fileURLToPath(import.meta.url), '..') + const envFromFile = readEnvFile(resolve(scriptDir, '../.env')) + const env = { ...envFromFile, ...process.env } + const { apiKey, accountSlug, projectSlug, profile } = resolveTenderlyCredentials(flags, env) + const rpcName = resolveRpcName(flags, env, profile) + const timestamp = Date.now().toString() + const requestedSlug = getArg(flags, 'slug') || `vnet-${timestamp}` + const defaultDisplayNamePrefix = profile === 'personal' ? 'Personal VNet' : 'Webops VNet' + const displayName = getArg(flags, 'display-name') || `${defaultDisplayNamePrefix} ${timestamp}` + const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID + const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) + const blockNumber = getArg(flags, 'block-number') || 'latest' + const enableSync = parseBooleanFlag(flags, 'enable-sync', false) + const enableExplorer = parseBooleanFlag(flags, 'enable-explorer', false) + const { envFilePath, responseFilePath } = resolveRequestedOutputPaths(flags) + + const payload: TCreateVnetPayload = { + slug: requestedSlug, + display_name: displayName, + fork_config: { + network_id: networkId, + block_number: blockNumber + }, + virtual_network_config: { + chain_config: { + chain_id: chainId + } + }, + sync_state_config: { + enabled: enableSync, + commitment_level: 'latest' + }, + explorer_page_config: { + enabled: enableExplorer, + verification_visibility: 'bytecode' + }, + ...(rpcName ? { rpc_config: { rpc_name: rpcName } } : {}) + } + + const response = await createVirtualTestNet(apiKey, accountSlug, projectSlug, payload) + const details = getConnectionDetails(response, accountSlug, projectSlug, rpcName) + if (envFilePath) { + writeOutputFile( + envFilePath, + `${buildTenderlyEnvFragment({ + canonicalChainId: networkId, + executionChainId: chainId, + details + })}\n` + ) + } + if (responseFilePath) { + writeOutputFile(responseFilePath, `${JSON.stringify(response, null, 2)}\n`) + } + + if ('json' in flags) { + console.log( + JSON.stringify( + buildSanitizedVnetJson({ + profile, + requestedSlug, + displayName, + chainId, + networkId, + response, + details, + envFilePath, + responseFilePath + }), + null, + 2 + ) + ) + return + } + + buildVnetConsoleSummary({ + profile, + requestedSlug, + displayName, + chainId, + networkId, + response, + details, + envFilePath, + responseFilePath + }).forEach((line) => { + console.log(line) + }) +} + +if (fileURLToPath(import.meta.url) === process.argv[1]) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? sanitizeConsoleText(error.message) : 'Tenderly VNet bootstrap failed' + console.error(message) + process.exitCode = 1 + }) +} diff --git a/scripts/tenderly.test.ts b/scripts/tenderly.test.ts new file mode 100644 index 000000000..908d5b0d9 --- /dev/null +++ b/scripts/tenderly.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { parseCliArgs, parseDecimalAmount, toHexQuantity } from './tenderly' + +describe('tenderly script helpers', () => { + it('parses command-line flags and positionals', () => { + expect( + parseCliArgs([ + 'fund-native', + '--wallet', + '0x1111111111111111111111111111111111111111', + '--amount', + '25', + '--mode', + 'add', + 'extra' + ]) + ).toEqual({ + command: 'fund-native', + flags: { + wallet: '0x1111111111111111111111111111111111111111', + amount: '25', + mode: 'add' + }, + positionals: ['extra'] + }) + }) + + it('parses boolean-style flags without values', () => { + expect(parseCliArgs(['increase-time', '--seconds', '86400', '--mine-block'])).toEqual({ + command: 'increase-time', + flags: { + seconds: '86400', + 'mine-block': 'true' + }, + positionals: [] + }) + }) + + it('converts decimal values to bigint token amounts', () => { + expect(parseDecimalAmount('1.5', 18)).toBe(1500000000000000000n) + expect(parseDecimalAmount('50000', 6)).toBe(50000000000n) + }) + + it('converts bigint values to JSON-RPC hex quantities', () => { + expect(toHexQuantity(0n)).toBe('0x0') + expect(toHexQuantity(255n)).toBe('0xff') + }) +}) diff --git a/scripts/tenderly.ts b/scripts/tenderly.ts new file mode 100644 index 000000000..750c8c50e --- /dev/null +++ b/scripts/tenderly.ts @@ -0,0 +1,352 @@ +/// + +import { fileURLToPath } from 'node:url' +import { getAddress } from 'viem' + +type TParsedCliArgs = { + command?: string + flags: Record + positionals: string[] +} + +type TTenderlyJsonRpcSuccess = { + id: string | number | null + jsonrpc: '2.0' + result: unknown +} + +type TTenderlyJsonRpcError = { + id: string | number | null + jsonrpc: '2.0' + error: { + code: number + message: string + data?: unknown + } +} + +const DEFAULT_CANONICAL_CHAIN_ID = 1 + +const HELP_TEXT = `Tenderly Admin RPC helper + +Usage: + bun run tenderly [options] + +Commands: + help + chain-id [--chain 1] + fund-native --wallet
--amount [--unit eth|wei] [--mode add|set] [--chain 1] + fund-erc20 --wallet
--token
--amount [--decimals 18] [--unit token|raw] [--chain 1] + snapshot [--chain 1] + revert --id [--chain 1] + increase-time --seconds [--mine-block] [--chain 1] + set-next-timestamp --timestamp [--chain 1] + increase-blocks --count [--chain 1] + raw --method [--params ''] [--chain 1] + +Examples: + bun run tenderly chain-id + bun run tenderly fund-native --wallet 0xabc... --amount 25 --mode add + bun run tenderly fund-erc20 --wallet 0xabc... --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --amount 50000 --decimals 6 + bun run tenderly snapshot + bun run tenderly revert --id 0x1 + bun run tenderly increase-time --seconds 86400 --mine-block + bun run tenderly raw --method tenderly_setBalance --params '[["0xabc..."], "0x3635C9ADC5DEA00000"]' +` + +export function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const [command, ...rest] = argv + const flags: Record = {} + const positionals: string[] = [] + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index] + if (!token.startsWith('--')) { + positionals.push(token) + continue + } + + const key = token.slice(2) + const nextToken = rest[index + 1] + + if (!nextToken || nextToken.startsWith('--')) { + flags[key] = 'true' + continue + } + + flags[key] = nextToken + index += 1 + } + + return { command, flags, positionals } +} + +export function parseDecimalAmount(value: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals value: ${decimals}`) + } + + const normalizedValue = value.trim() + if (normalizedValue.length === 0) { + throw new Error('Amount is required') + } + + if (normalizedValue.startsWith('-')) { + throw new Error('Negative amounts are not supported') + } + + if (!/^\d+(\.\d+)?$/.test(normalizedValue)) { + throw new Error(`Invalid decimal amount: ${value}`) + } + + const [wholePartRaw, fractionalPartRaw = ''] = normalizedValue.split('.') + if (fractionalPartRaw.length > decimals) { + throw new Error(`Amount ${value} exceeds ${decimals} decimals`) + } + + const wholePart = BigInt(wholePartRaw) + const fractionalPart = fractionalPartRaw.length ? BigInt(fractionalPartRaw.padEnd(decimals, '0')) : 0n + + return wholePart * 10n ** BigInt(decimals) + fractionalPart +} + +export function toHexQuantity(value: bigint): `0x${string}` { + if (value < 0n) { + throw new Error('Negative quantities are not supported') + } + + return `0x${value.toString(16)}` as const +} + +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`) + } + return parsed +} + +function requireFlag(flags: Record, ...keys: string[]): string { + for (const key of keys) { + const value = flags[key]?.trim() + if (value) { + return value + } + } + + throw new Error(`Missing required flag: --${keys[0]}`) +} + +function optionalFlag(flags: Record, key: string): string | undefined { + const value = flags[key]?.trim() + return value ? value : undefined +} + +function hasFlag(flags: Record, key: string): boolean { + const value = optionalFlag(flags, key) + return value === 'true' || value === '1' +} + +function getCanonicalChainId(flags: Record): number { + const chainValue = optionalFlag(flags, 'chain') + return chainValue ? parsePositiveInteger(chainValue, 'chain') : DEFAULT_CANONICAL_CHAIN_ID +} + +function getAdminRpcUri(canonicalChainId: number): string { + const envName = `TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}` + const value = process.env[envName]?.trim() + + if (!value) { + throw new Error(`Missing ${envName} in the environment`) + } + + return value +} + +async function callAdminRpc(canonicalChainId: number, method: string, params: unknown[]): Promise { + const rpcUri = getAdminRpcUri(canonicalChainId) + const response = await fetch(rpcUri, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + throw new Error(`Tenderly RPC request failed with status ${response.status}`) + } + + const payload = (await response.json()) as TTenderlyJsonRpcSuccess | TTenderlyJsonRpcError + + if ('error' in payload) { + throw new Error(`${payload.error.message} (code ${payload.error.code})`) + } + + return payload.result +} + +function parseWalletAddress(flags: Record): `0x${string}` { + return getAddress(requireFlag(flags, 'wallet', 'address')) +} + +function parseTokenAddress(flags: Record): `0x${string}` { + return getAddress(requireFlag(flags, 'token')) +} + +function parseNativeAmount(flags: Record): bigint { + const amount = requireFlag(flags, 'amount') + const unit = optionalFlag(flags, 'unit') || 'eth' + + if (unit === 'wei' || unit === 'raw') { + return amount.startsWith('0x') ? BigInt(amount) : BigInt(amount) + } + + if (unit !== 'eth') { + throw new Error(`Unsupported native unit: ${unit}`) + } + + return parseDecimalAmount(amount, 18) +} + +function parseTokenAmount(flags: Record): bigint { + const amount = requireFlag(flags, 'amount') + const unit = optionalFlag(flags, 'unit') || 'token' + + if (unit === 'raw' || unit === 'wei') { + return amount.startsWith('0x') ? BigInt(amount) : BigInt(amount) + } + + const decimals = parsePositiveInteger(optionalFlag(flags, 'decimals') || '18', 'decimals') + return parseDecimalAmount(amount, decimals) +} + +function formatResult(result: unknown): string { + if (typeof result === 'string') { + return result + } + + return JSON.stringify(result, null, 2) +} + +async function runCommand(parsedArgs: TParsedCliArgs): Promise { + const { command, flags } = parsedArgs + const canonicalChainId = getCanonicalChainId(flags) + + switch (command) { + case undefined: + case 'help': { + console.log(HELP_TEXT) + return + } + case 'chain-id': { + const result = await callAdminRpc(canonicalChainId, 'eth_chainId', []) + const chainIdHex = String(result) + const chainIdDecimal = Number(BigInt(chainIdHex)) + console.log(`canonical chain: ${canonicalChainId}`) + console.log(`execution chain: ${chainIdDecimal} (${chainIdHex})`) + return + } + case 'fund-native': { + const wallet = parseWalletAddress(flags) + const amount = parseNativeAmount(flags) + const mode = optionalFlag(flags, 'mode') || 'add' + const method = mode === 'set' ? 'tenderly_setBalance' : mode === 'add' ? 'tenderly_addBalance' : undefined + + if (!method) { + throw new Error(`Unsupported fund-native mode: ${mode}`) + } + + const result = await callAdminRpc(canonicalChainId, method, [[wallet], toHexQuantity(amount)]) + console.log( + `${mode === 'set' ? 'Set' : 'Added'} native balance for ${wallet} on canonical chain ${canonicalChainId}` + ) + console.log(formatResult(result)) + return + } + case 'fund-erc20': { + const wallet = parseWalletAddress(flags) + const token = parseTokenAddress(flags) + const amount = parseTokenAmount(flags) + const result = await callAdminRpc(canonicalChainId, 'tenderly_setErc20Balance', [ + token, + wallet, + toHexQuantity(amount) + ]) + console.log(`Set ERC-20 balance for ${wallet} on token ${token} (canonical chain ${canonicalChainId})`) + console.log(formatResult(result)) + return + } + case 'snapshot': { + const result = await callAdminRpc(canonicalChainId, 'evm_snapshot', []) + console.log(`Snapshot created on canonical chain ${canonicalChainId}: ${formatResult(result)}`) + return + } + case 'revert': { + const snapshotId = requireFlag(flags, 'id') + const result = await callAdminRpc(canonicalChainId, 'evm_revert', [snapshotId]) + console.log(`Revert result on canonical chain ${canonicalChainId}: ${formatResult(result)}`) + return + } + case 'increase-time': { + const seconds = parsePositiveInteger(requireFlag(flags, 'seconds'), 'seconds') + const shouldMineBlock = hasFlag(flags, 'mine-block') + const result = await callAdminRpc(canonicalChainId, 'evm_increaseTime', [toHexQuantity(BigInt(seconds))]) + console.log(`Advanced time by ${seconds} seconds on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + if (shouldMineBlock) { + const minedBlockResult = await callAdminRpc(canonicalChainId, 'evm_mine', []) + console.log(`Mined 1 block on canonical chain ${canonicalChainId}`) + console.log(formatResult(minedBlockResult)) + } + return + } + case 'set-next-timestamp': { + const timestamp = parsePositiveInteger(requireFlag(flags, 'timestamp'), 'timestamp') + const result = await callAdminRpc(canonicalChainId, 'evm_setNextBlockTimestamp', [ + toHexQuantity(BigInt(timestamp)) + ]) + console.log(`Set next block timestamp to ${timestamp} on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + return + } + case 'increase-blocks': { + const count = parsePositiveInteger(requireFlag(flags, 'count'), 'count') + const result = await callAdminRpc(canonicalChainId, 'evm_increaseBlocks', [toHexQuantity(BigInt(count))]) + console.log(`Advanced ${count} blocks on canonical chain ${canonicalChainId}`) + console.log(formatResult(result)) + return + } + case 'raw': { + const method = requireFlag(flags, 'method') + const rawParams = optionalFlag(flags, 'params') || '[]' + const params = JSON.parse(rawParams) as unknown + if (!Array.isArray(params)) { + throw new Error('--params must be a JSON array') + } + + const result = await callAdminRpc(canonicalChainId, method, params) + console.log(formatResult(result)) + return + } + default: { + throw new Error(`Unknown Tenderly command: ${command}`) + } + } +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + await runCommand(parsedArgs) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exitCode = 1 + }) +} diff --git a/src/App.tsx b/src/App.tsx index 3291c7361..e69bb56b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { ChartStyleContextApp } from '@shared/contexts/useChartStyle' import { IndexedDB } from '@shared/contexts/useIndexedDB' import { WithNotifications } from '@shared/contexts/useNotifications' import { WithNotificationsActions } from '@shared/contexts/useNotificationsActions' +import { TenderlyPanelProvider } from '@shared/contexts/useTenderlyPanel' import { WalletContextApp } from '@shared/contexts/useWallet' import { Web3ContextApp } from '@shared/contexts/useWeb3' import { YearnContextApp } from '@shared/contexts/useYearn' @@ -26,6 +27,7 @@ import { useLayoutEffect } from 'react' import { Toaster } from 'react-hot-toast' import { useLocation } from 'react-router' import { WagmiProvider } from 'wagmi' +import { TenderlyControlPanel } from '@/components/shared/components/TenderlyControlPanel' import { wagmiConfig } from '@/config/wagmi' import { ChainsProvider } from '@/context/ChainsProvider' import '@hooks/usePlausible' @@ -102,7 +104,10 @@ function App(): ReactElement { - + + + + diff --git a/src/components/pages/portfolio/index.tsx b/src/components/pages/portfolio/index.tsx index 1c74f4f3f..bf5a797af 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -26,6 +26,7 @@ import { Tooltip } from '@shared/components/Tooltip' import { useNotifications } from '@shared/contexts/useNotifications' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' +import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconSpinner } from '@shared/icons/IconSpinner' import type { TSortDirection } from '@shared/types' @@ -35,7 +36,6 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { CSSProperties, ReactElement } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' -import { useChainId, useSwitchChain } from 'wagmi' const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/src/components/pages/vaults/components/notifications/Notification.tsx b/src/components/pages/vaults/components/notifications/Notification.tsx index 0d59d2cc0..efdc4968b 100644 --- a/src/components/pages/vaults/components/notifications/Notification.tsx +++ b/src/components/pages/vaults/components/notifications/Notification.tsx @@ -8,6 +8,7 @@ import { IconCross } from '@shared/icons/IconCross' import { IconLoader } from '@shared/icons/IconLoader' import type { TNotification, TNotificationStatus } from '@shared/types/notifications' import { cl, SUPPORTED_NETWORKS, truncateHex } from '@shared/utils' +import { getNetwork } from '@shared/utils/wagmi' import type { ReactElement } from 'react' import { memo, useCallback, useMemo, useState } from 'react' import Link from '/src/components/Link' @@ -549,10 +550,9 @@ export const Notification = memo(function Notification({ return null } - const chain = SUPPORTED_NETWORKS.find((network) => network.id === notification.chainId) - const explorerBaseURI = chain?.blockExplorers?.default?.url || 'https://etherscan.io' - return `${explorerBaseURI}/tx/${notification.txHash}` - }, [notification.chainId, notification.txHash]) + const explorerBaseURI = getNetwork(notification.executionChainId ?? notification.chainId).defaultBlockExplorer + return explorerBaseURI ? `${explorerBaseURI}/tx/${notification.txHash}` : null + }, [notification.chainId, notification.executionChainId, notification.txHash]) const notificationTitle = useMemo(() => { switch (notification.type) { diff --git a/src/components/pages/vaults/components/widget/WalletPanel.tsx b/src/components/pages/vaults/components/widget/WalletPanel.tsx index 762cdec98..53235ea2f 100644 --- a/src/components/pages/vaults/components/widget/WalletPanel.tsx +++ b/src/components/pages/vaults/components/widget/WalletPanel.tsx @@ -17,6 +17,7 @@ import { useNotifications } from '@shared/contexts/useNotifications' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { useReadContract } from '@shared/hooks/useAppWagmi' import { useChainTimestamp } from '@shared/hooks/useChainTimestamp' import { IconCheck } from '@shared/icons/IconCheck' import { IconCross } from '@shared/icons/IconCross' @@ -35,7 +36,6 @@ import { import { getNetwork } from '@shared/utils/wagmi/utils' import { type FC, type ReactElement, useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import { useReadContract } from 'wagmi' import { formatDuration, parseCooldownStatus, resolveCooldownWindowState } from './yvUSD/cooldownUtils' type WalletPanelProps = { diff --git a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx index 2805dac8e..4fbdfffe5 100644 --- a/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx +++ b/src/components/pages/vaults/components/widget/deposit/ApprovalOverlay.tsx @@ -1,8 +1,10 @@ import { Button } from '@shared/components/Button' +import { useSwitchChain, useWaitForTransactionReceipt } from '@shared/hooks/useAppWagmi' import { getApproveAbi } from '@shared/utils/approve' import { type FC, useCallback, useEffect, useState } from 'react' import { maxUint256 } from 'viem' -import { useAccount, useChainId, useSwitchChain, useWaitForTransactionReceipt, useWriteContract } from 'wagmi' +import { useAccount, useWriteContract } from 'wagmi' +import { isConnectedToExecutionChain, resolveExecutionChainId } from '@/config/tenderly' import { InfoOverlay } from '../shared/InfoOverlay' import { AnimatedCheckmark, ErrorIcon, Spinner } from '../shared/TransactionStateIndicators' @@ -33,11 +35,10 @@ export const ApprovalOverlay: FC = ({ const [txState, setTxState] = useState('idle') const [errorMessage, setErrorMessage] = useState('') - const { address: account } = useAccount() - const currentChainId = useChainId() + const { address: account, chain } = useAccount() const { switchChainAsync } = useSwitchChain() const { writeContractAsync, data: txHash, reset } = useWriteContract() - const receipt = useWaitForTransactionReceipt({ hash: txHash }) + const receipt = useWaitForTransactionReceipt({ hash: txHash, chainId }) // Reset state when overlay closes useEffect(() => { @@ -67,11 +68,18 @@ export const ApprovalOverlay: FC = ({ const handleApprove = useCallback( async (amount: bigint) => { + const executionChainId = resolveExecutionChainId(chainId) + if (executionChainId === undefined) { + setTxState('error') + setErrorMessage('This network is not enabled for transactions') + return + } + setTxState('confirming') setErrorMessage('') // Handle chain switch if needed - if (currentChainId !== chainId) { + if (!isConnectedToExecutionChain(chain?.id, chainId)) { try { await switchChainAsync({ chainId }) } catch { @@ -87,7 +95,7 @@ export const ApprovalOverlay: FC = ({ abi: getApproveAbi(tokenAddress), functionName: 'approve', args: [spenderAddress, amount], - chainId + chainId: executionChainId }) setTxState('pending') } catch (error: any) { @@ -104,7 +112,7 @@ export const ApprovalOverlay: FC = ({ } } }, - [currentChainId, chainId, tokenAddress, spenderAddress, writeContractAsync, switchChainAsync] + [chain?.id, chainId, tokenAddress, spenderAddress, writeContractAsync, switchChainAsync] ) const handleRevoke = useCallback(() => handleApprove(0n), [handleApprove]) diff --git a/src/components/pages/vaults/components/widget/migrate/index.tsx b/src/components/pages/vaults/components/widget/migrate/index.tsx index a8f0d3cb9..f401c9f00 100644 --- a/src/components/pages/vaults/components/widget/migrate/index.tsx +++ b/src/components/pages/vaults/components/widget/migrate/index.tsx @@ -4,13 +4,14 @@ import { Button } from '@shared/components/Button' import { TokenLogo } from '@shared/components/TokenLogo' import { useWallet } from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' +import { usePublicClient } from '@shared/hooks/useAppWagmi' import { PERMIT_ABI, type TPermitSignature } from '@shared/hooks/usePermit' import { IconLinkOut } from '@shared/icons/IconLinkOut' import { formatTAmount, isZeroAddress, toAddress, toNormalizedBN } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import { type FC, useCallback, useEffect, useMemo, useState } from 'react' import { hexToNumber, slice } from 'viem' -import { useAccount, usePublicClient } from 'wagmi' +import { useAccount } from 'wagmi' import { useYearn } from '@/components/shared/contexts/useYearn' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { formatWidgetValue } from '../shared/valueDisplay' diff --git a/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts b/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts index d9139752f..e38d688f6 100644 --- a/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts +++ b/src/components/pages/vaults/components/widget/migrate/useMigrateFlow.ts @@ -1,11 +1,11 @@ import { useTokenAllowance } from '@pages/vaults/hooks/useTokenAllowance' import type { MigrateRouteType, UseMigrateFlowReturn } from '@pages/vaults/types' import { ERC_4626_ROUTER_ABI } from '@shared/contracts/abi/erc4626Router.abi' +import { type AppUseSimulateContractReturnType, usePublicClient, useSimulateContract } from '@shared/hooks/useAppWagmi' import { detectPermitType, type PermitType, type TPermitSignature } from '@shared/hooks/usePermit' import { getMigratorConfig, type MigratorConfig } from '@shared/utils/migratorRegistry' import { useEffect, useMemo, useState } from 'react' import { type Address, encodeFunctionData, erc20Abi } from 'viem' -import { type UseSimulateContractReturnType, usePublicClient, useSimulateContract } from 'wagmi' import { YEARN_4626_ROUTER } from '@/components/shared/utils' // Default permit deadline: 20 minutes from now @@ -105,7 +105,7 @@ export const useMigrateFlow = ({ // Prepare approve (when using approve flow and allowance insufficient) const prepareApproveEnabled = routeType === 'approve' && !isAllowanceSufficient && hasBalance && !!account && enabled - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc20Abi, functionName: 'approve', address: vaultFrom, @@ -115,7 +115,7 @@ export const useMigrateFlow = ({ }) const prepareMigrateEnabled = routeType === 'approve' && hasBalance && !!account && enabled && isAllowanceSufficient - const prepareMigrate: UseSimulateContractReturnType = useSimulateContract({ + const prepareMigrate: AppUseSimulateContractReturnType = useSimulateContract({ abi: migratorConfig.abi, functionName: migratorConfig.functionName, address: effectiveRouter, @@ -160,7 +160,7 @@ export const useMigrateFlow = ({ migratorConfig.functionName ]) - const prepareMulticall: UseSimulateContractReturnType = useSimulateContract({ + const prepareMulticall: AppUseSimulateContractReturnType = useSimulateContract({ abi: ERC_4626_ROUTER_ABI, functionName: 'multicall', address: effectiveRouter, diff --git a/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx b/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx index 93c5c262e..6ec086afd 100644 --- a/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx +++ b/src/components/pages/vaults/components/widget/rewards/MerkleRewardRow.tsx @@ -1,9 +1,9 @@ import { useClaimMerkleRewards } from '@pages/vaults/hooks/rewards/useClaimMerkleRewards' +import { useChainId } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useMemo } from 'react' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useChainId, useWriteContract } from 'wagmi' +import { useWriteContract } from 'wagmi' import type { TransactionStep } from '../shared/TransactionOverlay' import { RewardRow } from './RewardRow' import type { TGroupedMerkleReward } from './types' @@ -38,7 +38,7 @@ export function MerkleRewardRow(props: TMerkleRewardRowProps): ReactElement { return undefined } return { - prepare: prepare as unknown as UseSimulateContractReturnType, + prepare, label: 'Claim', confirmMessage: `Claim ${formattedAmount} ${groupedReward.token.symbol}`, successTitle: 'Rewards Claimed', diff --git a/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx b/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx index 4dcb46aad..9e02ba9c5 100644 --- a/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx +++ b/src/components/pages/vaults/components/widget/rewards/StakingRewardRow.tsx @@ -1,9 +1,9 @@ import { useClaimStakingRewards } from '@pages/vaults/hooks/rewards/useClaimStakingRewards' +import { useChainId } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useMemo } from 'react' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useChainId, useWriteContract } from 'wagmi' +import { useWriteContract } from 'wagmi' import type { TransactionStep } from '../shared/TransactionOverlay' import { RewardRow } from './RewardRow' import type { TStakingReward } from './types' @@ -41,7 +41,7 @@ export function StakingRewardRow(props: TStakingRewardRowProps): ReactElement { return undefined } return { - prepare: prepare as unknown as UseSimulateContractReturnType, + prepare, label: 'Claim', confirmMessage: `Claim ${formattedAmount} ${reward.symbol}`, successTitle: 'Rewards Claimed', diff --git a/src/components/pages/vaults/components/widget/rewards/index.tsx b/src/components/pages/vaults/components/widget/rewards/index.tsx index b6cc1fdcc..49cecb63c 100644 --- a/src/components/pages/vaults/components/widget/rewards/index.tsx +++ b/src/components/pages/vaults/components/widget/rewards/index.tsx @@ -4,13 +4,13 @@ import { type TRewardToken, useStakingRewards } from '@pages/vaults/hooks/reward import { Button } from '@shared/components/Button' import { SwitchChainPrompt } from '@shared/components/SwitchChainPrompt' import { useWeb3 } from '@shared/contexts/useWeb3' +import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' import { IconCross } from '@shared/icons/IconCross' import { cl, toAddress } from '@shared/utils' import { formatUSDWithThreshold } from '@shared/utils/format' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useChainId, useSwitchChain } from 'wagmi' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { MerkleRewardRow } from './MerkleRewardRow' import { StakingRewardRow } from './StakingRewardRow' diff --git a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx index 7c912f66a..78d8c9e01 100644 --- a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx +++ b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.tsx @@ -1,21 +1,20 @@ import { Button } from '@shared/components/Button' import { useNotificationsActions } from '@shared/contexts/useNotificationsActions' +import { + type AppUseSimulateContractReturnType, + useChainId, + useSwitchChain, + useWaitForTransactionReceipt +} from '@shared/hooks/useAppWagmi' import type { TCreateNotificationParams } from '@shared/types/notifications' import { cl } from '@shared/utils' -import { getNetwork } from '@shared/utils/wagmi' +import { getNetwork, retrieveConfig } from '@shared/utils/wagmi' +import { getPublicClient } from '@wagmi/core' import { type FC, useCallback, useEffect, useId, useRef, useState } from 'react' import { useReward } from 'react-rewards' import type { TypedData, TypedDataDomain } from 'viem' -import { - type UseSimulateContractReturnType, - useAccount, - useChainId, - usePublicClient, - useSignTypedData, - useSwitchChain, - useWaitForTransactionReceipt, - useWriteContract -} from 'wagmi' +import { useAccount, useSignTypedData, useWriteContract } from 'wagmi' +import { isConnectedToExecutionChain } from '@/config/tenderly' import { AnimatedCheckmark, ErrorIcon, Spinner } from './TransactionStateIndicators' import { type OverlayState, @@ -37,7 +36,7 @@ export type PermitDataAsync = { export type PermitData = PermitDataDirect | PermitDataAsync export type TransactionStep = { - prepare: UseSimulateContractReturnType + prepare: AppUseSimulateContractReturnType label: string confirmMessage: string successTitle: string @@ -56,7 +55,7 @@ type TPrepareDebugInfo = { isError: boolean isLoading: boolean isFetching: boolean - status: UseSimulateContractReturnType['status'] + status: AppUseSimulateContractReturnType['status'] error?: string request?: { chainId?: number @@ -65,7 +64,7 @@ type TPrepareDebugInfo = { } } -function getPrepareDebugInfo(prepare?: UseSimulateContractReturnType): TPrepareDebugInfo | undefined { +function getPrepareDebugInfo(prepare?: AppUseSimulateContractReturnType): TPrepareDebugInfo | undefined { if (!prepare) return undefined const request = prepare.data?.request as any @@ -170,8 +169,7 @@ export const TransactionOverlay: FC = ({ const currentChainId = useChainId() const { switchChainAsync } = useSwitchChain() const [txHash, setTxHash] = useState<`0x${string}` | undefined>() - const client = usePublicClient() - const { address: account } = useAccount() + const { address: account, chain } = useAccount() // Notification system integration const { createNotification, updateNotification } = useNotificationsActions() @@ -183,10 +181,10 @@ export const TransactionOverlay: FC = ({ // Track the step that was just executed (for showing success messages) const executedStepRef = useRef(null) - const receipt = useWaitForTransactionReceipt({ hash: txHash, confirmations }) const explorerChainId = - ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? currentChainId - const blockExplorer = explorerChainId ? getNetwork(explorerChainId).defaultBlockExplorer : '' + ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? undefined + const receipt = useWaitForTransactionReceipt({ hash: txHash, chainId: explorerChainId, confirmations }) + const blockExplorer = getNetwork(explorerChainId ?? currentChainId).defaultBlockExplorer const explorerTxUrl = txHash && blockExplorer ? `${blockExplorer}/tx/${txHash}` : '' // Track if the executed step was the last step (captured at execution time) @@ -305,12 +303,16 @@ export const TransactionOverlay: FC = ({ async ( txHash: `0x${string}`, notification?: TCreateNotificationParams, + executionChainId?: number, status: 'pending' | 'submitted' = 'pending' ): Promise => { if (!notification || !account) return undefined try { - const id = await createNotification(notification) + const id = await createNotification({ + ...notification, + executionChainId: executionChainId ?? notification.executionChainId + }) setNotificationId(id) await updateNotification({ id, txHash, status }) return id @@ -403,7 +405,7 @@ export const TransactionOverlay: FC = ({ const request = currentStep.prepare.data.request as any const txChainId = request.chainId - const wrongNetwork = txChainId && currentChainId !== txChainId + const wrongNetwork = txChainId && !isConnectedToExecutionChain(chain?.id, txChainId) if (wrongNetwork && txChainId) { try { @@ -427,7 +429,7 @@ export const TransactionOverlay: FC = ({ } if (isCrossChain) { - await handleCreateNotification(result.hash, currentStep.notification, 'submitted') + await handleCreateNotification(result.hash, currentStep.notification, txChainId, 'submitted') if (currentStep.showConfetti) { setTimeout(() => reward(), 100) } @@ -439,17 +441,18 @@ export const TransactionOverlay: FC = ({ setTxHash(result.hash) setOverlayState('pending') - await handleCreateNotification(result.hash, currentStep.notification) + await handleCreateNotification(result.hash, currentStep.notification, txChainId) return } - const gasOverrides: { gas?: bigint } = client - ? await client + const gasEstimateClient = txChainId ? getPublicClient(retrieveConfig(), { chainId: txChainId }) : undefined + const gasOverrides: { gas?: bigint } = gasEstimateClient + ? await gasEstimateClient .estimateContractGas(request) - .then((gasEstimate) => ({ + .then((gasEstimate: bigint) => ({ gas: (gasEstimate * BigInt(110)) / BigInt(100) })) - .catch((error) => { + .catch((error: unknown) => { console.warn('[TransactionOverlay] Gas estimation failed', { step: currentStep.label, error: (error as Error)?.message || error @@ -464,7 +467,7 @@ export const TransactionOverlay: FC = ({ }) setTxHash(hash) setOverlayState('pending') - await handleCreateNotification(hash, currentStep.notification) + await handleCreateNotification(hash, currentStep.notification, txChainId) } catch (error: any) { if (isUserRejectionError(error)) { onClose() @@ -476,8 +479,7 @@ export const TransactionOverlay: FC = ({ } }, [ - client, - currentChainId, + chain?.id, finalizeSuccessState, handleCreateNotification, isLastStep, @@ -517,41 +519,41 @@ export const TransactionOverlay: FC = ({ executeStep() }, [resetTxState, executeStep]) - const waitForAutoContinueBlock = useCallback( - async (executedStepLabel?: string) => { - // Most flows can continue immediately once the next simulation is ready. - // Unstake -> withdraw can race state propagation, so wait one block there. - if (executedStepLabel !== 'Unstake') return + const waitForAutoContinueBlock = useCallback(async (executedStepLabel?: string) => { + // Most flows can continue immediately once the next simulation is ready. + // Unstake -> withdraw can race state propagation, so wait one block there. + if (executedStepLabel !== 'Unstake') return - const executedBlockNumber = executedStepBlockRef.current - if (!client || executedBlockNumber === undefined) return + const executedBlockNumber = executedStepBlockRef.current + const executedChainId = + ((executedStepRef.current?.prepare.data?.request as any)?.chainId as number | undefined) ?? undefined + const blockClient = executedChainId ? getPublicClient(retrieveConfig(), { chainId: executedChainId }) : undefined + if (!blockClient || executedBlockNumber === undefined) return - const targetBlock = executedBlockNumber + 1n - const timeoutMs = 20_000 - const pollIntervalMs = 1_000 - const startedAt = Date.now() + const targetBlock = executedBlockNumber + 1n + const timeoutMs = 20_000 + const pollIntervalMs = 1_000 + const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - try { - const latestBlock = await client.getBlockNumber() - if (latestBlock >= targetBlock) { - return - } - } catch (error) { - console.warn('[TransactionOverlay] Auto-continue block polling failed', { - step: executedStepRef.current?.label, - error: (error as Error)?.message || error - }) + while (Date.now() - startedAt < timeoutMs) { + try { + const latestBlock = await blockClient.getBlockNumber() + if (latestBlock >= targetBlock) { return } - - await new Promise((resolve) => { - window.setTimeout(resolve, pollIntervalMs) + } catch (error) { + console.warn('[TransactionOverlay] Auto-continue block polling failed', { + step: executedStepRef.current?.label, + error: (error as Error)?.message || error }) + return } - }, - [client] - ) + + await new Promise((resolve) => { + window.setTimeout(resolve, pollIntervalMs) + }) + } + }, []) useEffect(() => { if (step?.prepare.isError) { diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx index ad75fe096..5d6a683ef 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx @@ -14,14 +14,14 @@ import { Button } from '@shared/components/Button' import { useWallet } from '@shared/contexts/useWallet' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { useChainTimestamp } from '@shared/hooks/useChainTimestamp' import { IconCheck } from '@shared/icons/IconCheck' import { formatTAmount, toAddress } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { formatUnits } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useAccount, useReadContract, useSimulateContract } from 'wagmi' +import { useAccount } from 'wagmi' import { InfoOverlay } from '../shared/InfoOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { WidgetWithdraw } from '../withdraw' @@ -142,13 +142,8 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const [pendingPrefillAmount, setPendingPrefillAmount] = useState(undefined) const [pendingPrefillAddress, setPendingPrefillAddress] = useState<`0x${string}` | undefined>(undefined) const [prefillRequestKey, setPrefillRequestKey] = useState(0) - const [lockedWithdrawPhase, setLockedWithdrawPhase] = useState<'withdraw' | 'redeem'>('withdraw') - const [lockedWithdrawExecutionSnapshot, setLockedWithdrawExecutionSnapshot] = - useState(null) - const [selectedLockedWithdrawTokenAddress, setSelectedLockedWithdrawTokenAddress] = useState< - `0x${string}` | undefined - >(undefined) - const [selectedLockedWithdrawChainId, setSelectedLockedWithdrawChainId] = useState(undefined) + const [lockedRequestedAmountRaw, setLockedRequestedAmountRaw] = useState(0n) + const [selectedWithdrawTokenAddress, setSelectedWithdrawTokenAddress] = useState<`0x${string}` | undefined>(undefined) const activeVariant = variant ?? 'unlocked' const isLockedVariant = activeVariant === 'locked' @@ -264,7 +259,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap nowTimestamp, cooldownEnd: cooldownStatus.cooldownEnd, windowEnd: cooldownStatus.windowEnd, - availableWithdrawLimit: maxWithdrawAssets + availableWithdrawLimit }) const needsCooldownStart = hasLocked && (!hasActiveCooldown || isCooldownWindowExpired) @@ -486,7 +481,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap windowRemainingSeconds ) - const prepareStartCooldown: UseSimulateContractReturnType = useSimulateContract({ + const prepareStartCooldown: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: yvUsdLockedVaultAbi, functionName: 'startCooldown', @@ -497,7 +492,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap enabled: !!account && isLockedVariant && needsCooldownStart && cooldownSharesToStart > 0n } }) - const prepareCancelCooldown: UseSimulateContractReturnType = useSimulateContract({ + const prepareCancelCooldown: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: yvUsdLockedVaultAbi, functionName: 'cancelCooldown', @@ -1030,61 +1025,19 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const selectedVault = isLockedVariant ? lockedVault : unlockedVault const selectedRouteAssetAddress = isLockedVariant ? lockedAssetAddress : unlockedAssetAddress const selectedVaultUserData = isLockedVariant ? lockedDisplayUserData : unlockedUserData - const usesLockedManagedWithdrawFlow = - isLockedVariant && - !!account && - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow, - selectedTokenAddress: selectedLockedWithdrawTokenAddress, - selectedChainId: selectedLockedWithdrawChainId, - chainId, - underlyingAssetAddress: unlockedAssetAddress - }) const disableLockedAmountInput = isLockedVariant && isCooldownActive && !canWithdrawNow - const hideLockedWithdrawAction = usesLockedManagedWithdrawFlow - const lockedWithdrawInputError = - !isLockedVariant || !account || draftWithdrawAmount <= 0n - ? undefined - : !canWithdrawNow - ? maxCooldownAssetAmount > 0n && lockedRequestedCooldownAssets > maxCooldownAssetAmount - ? 'Insufficient balance' - : undefined - : !shouldNormalizeLockedWithdrawMaxInput && - lockedMaxWithdrawDisplayAmount > 0n && - draftWithdrawAmount > lockedMaxWithdrawDisplayAmount - ? 'Amount exceeds currently available withdraw limit.' - : undefined - const lockedReadyWithdrawStep = - !account || - executionLockedWithdrawAssets <= 0n || - (lockedWithdrawPhase === 'withdraw' && !canWithdrawNow) || - (lockedWithdrawPhase === 'redeem' && !hasLockedWithdrawExecutionSnapshot) || - executionUnderlyingWithdrawAssets <= 0n - ? undefined - : buildLockedWithdrawTransactionStep({ - phase: lockedWithdrawPhase, - lockedStepMethod: executionLockedWithdrawMethod, - prepareLockedWithdraw: prepareLockedWithdrawStep, - prepareUnlockedWithdraw: prepareUnlockedWithdraw, - requestedLockedShares: executionLockedWithdrawShares, - receivedLockedAssets: executionLockedWithdrawAssets, - expectedUnderlyingOut: executionExpectedUnderlyingOut, - lockedVaultTokenDecimals, - lockedAssetDecimals, - underlyingDecimals: unlockedAssetDecimals, - lockedVaultTokenSymbol, - lockedAssetSymbol: lockedUserData.assetToken?.symbol ?? 'yvUSD', - underlyingSymbol: unlockedUserData.assetToken?.symbol ?? 'USDC' - }) - const isLockedWithdrawReady = - draftWithdrawAmount > 0n && - (currentLockedWithdrawMethod === 'redeem' - ? lockedRequestedWithdrawShares > 0n && lockedRequestedWithdrawShares <= maxRedeemShares - : lockedRequestedWithdrawAssets > 0n && lockedRequestedWithdrawAssets <= maxWithdrawAssets) && - executionUnderlyingWithdrawAssets > 0n && - !lockedWithdrawInputError && - !!lockedReadyWithdrawStep?.prepare.isSuccess && - !!lockedReadyWithdrawStep.prepare.data?.request + const hideLockedWithdrawAction = isLockedVariant && !!account && !canWithdrawNow + const effectiveLockedActionDisabledReason = + isLockedVariant && !hideLockedWithdrawAction ? lockedActionDisabledReason : undefined + const lockedRequestedShares = clampLockedRequestedShares( + lockedRequestedAmountRaw, + canWithdrawNow, + availableWithdrawSharesCap + ) + const lockedExpectedUnderlyingOut = + lockedDisplayPricePerShare > 0n + ? (lockedRequestedShares * lockedDisplayPricePerShare) / 10n ** BigInt(lockedVaultTokenDecimals) + : 0n const withdrawPrefill = getWithdrawPrefill( activeVariant, diff --git a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts index b40997612..0039c44cf 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectDeposit.ts @@ -1,10 +1,10 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { vaultAbi } from '@shared/contracts/abi/vaultV2.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import { type UseSimulateContractReturnType, useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseDirectDepositParams { @@ -43,7 +43,7 @@ export function useDirectDeposit(params: UseDirectDepositParams): UseWidgetDepos const prepareDepositEnabled = isAllowanceSufficient && isValidInput && !!params.account // Prepare approve transaction using useSimulateContract - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.assetAddress), functionName: 'approve', address: params.assetAddress, @@ -53,7 +53,7 @@ export function useDirectDeposit(params: UseDirectDepositParams): UseWidgetDepos }) // Prepare deposit transaction using useSimulateContract - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ abi: vaultAbi, functionName: 'deposit', address: params.vaultAddress, diff --git a/src/components/pages/vaults/hooks/actions/useDirectStake.ts b/src/components/pages/vaults/hooks/actions/useDirectStake.ts index 59397d0d9..dfc638e92 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectStake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectStake.ts @@ -1,7 +1,7 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import { type UseSimulateContractReturnType, useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' import { getDirectStakeCall, getStakePreviewCall, normalizeStakingSource } from './stakingAdapter' @@ -49,7 +49,7 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl const prepareDepositEnabled = isAllowanceSufficient && isValidInput && !!params.account // Prepare approve transaction - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.vaultAddress), functionName: 'approve', address: params.vaultAddress, @@ -64,7 +64,7 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl account: params.account }) - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ abi: stakeCall.abi as any, functionName: stakeCall.functionName as any, address: params.stakingAddress, diff --git a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts index 92196fadf..fc589397a 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts @@ -1,7 +1,7 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import type { Address } from 'viem' import { maxUint256 } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' import { getDirectUnstakeCalls } from './stakingAdapter' interface UseDirectUnstakeParams { @@ -27,7 +27,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd maxRedeemShares: params.maxRedeemShares }) - const preparePrimaryWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const preparePrimaryWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ abi: unstakeCalls.primary.abi as any, functionName: unstakeCalls.primary.functionName as any, address: params.stakingAddress, @@ -40,7 +40,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd const shouldTryFallback = prepareWithdrawEnabled && !!unstakeCalls.fallback && !!params.stakingAddress && preparePrimaryWithdraw.isError - const prepareFallbackWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareFallbackWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ abi: (unstakeCalls.fallback?.abi || []) as any, functionName: (unstakeCalls.fallback?.functionName || 'withdraw') as any, address: params.stakingAddress, @@ -50,7 +50,7 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd query: { enabled: shouldTryFallback } }) - const prepareWithdraw: UseSimulateContractReturnType = + const prepareWithdraw: AppUseSimulateContractReturnType = unstakeCalls.fallback && preparePrimaryWithdraw.isError ? prepareFallbackWithdraw : preparePrimaryWithdraw return { diff --git a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts index 60fdb26a0..e003dd41e 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts @@ -1,11 +1,11 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { vaultAbi } from '@shared/contracts/abi/vaultV2.abi' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { useMemo } from 'react' import type { Address } from 'viem' import { maxUint256 } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' interface UseDirectWithdrawParams { vaultAddress: Address @@ -85,7 +85,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit // Prepare withdraw transaction using ERC4626 withdraw function // withdraw(assets, receiver, owner) - no approval needed when owner == msg.sender - const prepareWithdrawErc4626: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdrawErc4626: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc4626Abi, functionName: erc4626FunctionName, address: params.vaultAddress, @@ -95,7 +95,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit query: { enabled: prepareWithdrawEnabled && params.useErc4626 } }) - const prepareWithdrawV2: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdrawV2: AppUseSimulateContractReturnType = useSimulateContract({ abi: vaultAbi, functionName: 'withdraw', address: params.vaultAddress, @@ -105,7 +105,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit query: { enabled: prepareWithdrawEnabled && !params.useErc4626 } }) - const prepareWithdraw = useMemo((): UseSimulateContractReturnType => { + const prepareWithdraw = useMemo((): AppUseSimulateContractReturnType => { const livePrepare = params.useErc4626 ? prepareWithdrawErc4626 : prepareWithdrawV2 const expectedArgs = params.useErc4626 ? erc4626Args : withdrawV2Args const expectedFunctionName = params.useErc4626 ? erc4626FunctionName : 'withdraw' @@ -128,7 +128,7 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit ...livePrepare, data: undefined, isSuccess: false - } as UseSimulateContractReturnType + } as AppUseSimulateContractReturnType }, [ prepareWithdrawEnabled, params.useErc4626, diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts index 4e52ac611..0c128a384 100644 --- a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts @@ -1,11 +1,10 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' import { YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { type AppUseSimulateContractReturnType, useReadContract, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import type { Address } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseYvUsdLockedZapDepositParams { @@ -39,7 +38,7 @@ export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams) query: { enabled: params.enabled && isValidInput } }) - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(params.depositToken), functionName: 'approve', address: params.depositToken, @@ -48,7 +47,7 @@ export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams) query: { enabled: prepareApproveEnabled } }) - const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + const prepareDeposit: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ZAP_ADDRESS, abi: yvUsdLockedZapAbi, functionName: 'zapIn', diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts index ef2da7b7c..42596489b 100644 --- a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts @@ -1,11 +1,10 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' import { YVUSD_LOCKED_ADDRESS, YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import { toAddress } from '@shared/utils' import type { Address } from 'viem' import { erc20Abi } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' interface UseYvUsdLockedZapWithdrawParams { @@ -41,7 +40,7 @@ export function useYvUsdLockedZapWithdraw(params: UseYvUsdLockedZapWithdrawParam const prepareWithdrawEnabled = !!params.account && params.enabled && params.amount > 0n && params.requiredShares > 0n && isAllowanceSufficient - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: erc20Abi, functionName: 'approve', address: YVUSD_LOCKED_ADDRESS, @@ -50,7 +49,7 @@ export function useYvUsdLockedZapWithdraw(params: UseYvUsdLockedZapWithdrawParam query: { enabled: prepareApproveEnabled } }) - const prepareWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ZAP_ADDRESS, abi: yvUsdLockedZapAbi, functionName: 'zapOut', diff --git a/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts b/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts index c22098d55..fe02aea84 100644 --- a/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useClaimMerkleRewards.ts @@ -1,7 +1,7 @@ import type { TGroupedMerkleReward } from '@pages/vaults/components/widget/rewards/types' import { MERKLE_DISTRIBUTOR_ABI } from '@shared/contracts/abi/merkleDistributor.abi' +import { useSimulateContract } from '@shared/hooks/useAppWagmi' import { MERKLE_DISTRIBUTOR_ADDRESS } from '@shared/utils/constants' -import { useSimulateContract } from 'wagmi' type UseClaimMerkleRewardsParams = { groupedReward?: TGroupedMerkleReward diff --git a/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts b/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts index 6743668bd..d929cf4b7 100644 --- a/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useClaimStakingRewards.ts @@ -2,7 +2,7 @@ import { JUICED_STAKING_REWARDS_ABI } from '@shared/contracts/abi/juicedStakingR import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' import { V3_STAKING_REWARDS_ABI } from '@shared/contracts/abi/V3StakingRewards.abi' import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' -import { useSimulateContract } from 'wagmi' +import { useSimulateContract } from '@shared/hooks/useAppWagmi' type UseClaimStakingRewardsParams = { stakingAddress?: `0x${string}` diff --git a/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts b/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts index a5c883c87..efacbd5f5 100644 --- a/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts +++ b/src/components/pages/vaults/hooks/rewards/useStakingRewards.ts @@ -3,9 +3,11 @@ import { JUICED_STAKING_REWARDS_ABI } from '@shared/contracts/abi/juicedStakingR import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' import { V3_STAKING_REWARDS_ABI } from '@shared/contracts/abi/V3StakingRewards.abi' import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' +import { useReadContract } from '@shared/hooks/useAppWagmi' import { toNormalizedValue } from '@shared/utils' import { useCallback, useMemo } from 'react' -import { useReadContract, useReadContracts } from 'wagmi' +import { useReadContracts } from 'wagmi' +import { resolveExecutionChainId } from '@/config/tenderly' export type TRewardToken = { address: `0x${string}` @@ -32,6 +34,7 @@ type UseStakingRewardsReturn = { export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRewardsReturn { const { stakingAddress, stakingSource, rewardTokens, userAddress, chainId, enabled = true } = params + const executionChainId = resolveExecutionChainId(chainId) const isEnabled = enabled && !!stakingAddress && !!userAddress && rewardTokens.length > 0 @@ -74,9 +77,9 @@ export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRe abi: JUICED_STAKING_REWARDS_ABI, functionName: 'earned' as const, args: [userAddress, token.address] as const, - chainId + chainId: executionChainId })) - }, [isJuiced, stakingAddress, userAddress, rewardTokens, chainId]) + }, [executionChainId, isJuiced, stakingAddress, userAddress, rewardTokens]) const { data: juicedEarned, @@ -84,7 +87,7 @@ export function useStakingRewards(params: UseStakingRewardsParams): UseStakingRe refetch: refetchJuiced } = useReadContracts({ contracts: juicedContracts, - query: { enabled: isEnabled && isJuiced && juicedContracts.length > 0 } + query: { enabled: isEnabled && isJuiced && juicedContracts.length > 0 && !!executionChainId } }) // Legacy/OP Boost staking uses earned(account) for single reward token diff --git a/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts b/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts index a2d3e045c..5a19d27dc 100644 --- a/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts +++ b/src/components/pages/vaults/hooks/solvers/useSolverEnso.ts @@ -1,9 +1,9 @@ +import { type AppUseSimulateContractReturnType, useSimulateContract } from '@shared/hooks/useAppWagmi' import type { TNormalizedBN } from '@shared/types' import { isZeroAddress, toNormalizedBN } from '@shared/utils' import { getApproveAbi } from '@shared/utils/approve' import { useCallback, useEffect, useRef, useState } from 'react' import type { Address, Hex } from 'viem' -import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' const ENSO_ROUTE_PROXY = '/api/enso/route' @@ -53,7 +53,7 @@ interface UseSolverEnsoProps { interface UseSolverEnsoReturn { actions: { - prepareApprove: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType } periphery: { prepareApproveEnabled: boolean @@ -204,7 +204,7 @@ export const useSolverEnso = ({ const isValidInput = amountIn > 0n const isAllowanceSufficient = !allowanceSpender || allowance >= amountIn const prepareApproveEnabled = routerAddress && !isAllowanceSufficient && isValidInput && enabled - const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + const prepareApprove: AppUseSimulateContractReturnType = useSimulateContract({ abi: getApproveAbi(tokenIn), functionName: 'approve', address: tokenIn, diff --git a/src/components/pages/vaults/hooks/useEnsoOrder.ts b/src/components/pages/vaults/hooks/useEnsoOrder.ts index 949b2f730..2ef9ae343 100644 --- a/src/components/pages/vaults/hooks/useEnsoOrder.ts +++ b/src/components/pages/vaults/hooks/useEnsoOrder.ts @@ -1,8 +1,13 @@ +import { + type AppUseSimulateContractReturnType, + usePublicClient, + useWaitForTransactionReceipt +} from '@shared/hooks/useAppWagmi' import { useCallback, useEffect, useMemo, useState } from 'react' import type { Address, Hash, Hex } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' -import { usePublicClient, useWaitForTransactionReceipt, useWalletClient } from 'wagmi' -import { supportedChains } from '@/config/supportedChains' +import { useWalletClient } from 'wagmi' +import { supportedWalletChains } from '@/config/supportedChains' +import { resolveExecutionChainId } from '@/config/tenderly' interface EnsoTransaction { to: Address @@ -19,7 +24,7 @@ interface UseEnsoOrderProps { } interface UseEnsoOrderReturn { - prepareEnsoOrder: UseSimulateContractReturnType + prepareEnsoOrder: AppUseSimulateContractReturnType receiptSuccess: boolean txHash: Hash | undefined } @@ -34,12 +39,13 @@ export const useEnsoOrder = ({ const [error, setError] = useState(null) const [txHash, setTxHash] = useState() const [waitingForTx, setWaitingForTx] = useState(false) + const executionChainId = resolveExecutionChainId(chainId) // Don't specify chainId - use current chain. TxButton handles chain switching before execution. const publicClient = usePublicClient() const { data: walletClient } = useWalletClient() const { data: receipt, isSuccess: receiptSuccess } = useWaitForTransactionReceipt({ hash: txHash, - chainId + chainId: executionChainId }) const executeOrder = useCallback(async () => { @@ -51,10 +57,11 @@ export const useEnsoOrder = ({ if (!ensoTx) throw new Error('No Enso transaction data') if (!walletClient) throw new Error('No wallet client available') if (!publicClient) throw new Error('No public client available') + if (!executionChainId) throw new Error(`No execution chain configured for chain ${chainId}`) // Note: Chain switching is handled by TransactionOverlay before calling executeOrder // We use the target chain from props, not walletClient.chain which may be stale - const targetChain = supportedChains.find((c) => c.id === chainId) + const targetChain = supportedWalletChains.find((c) => c.id === executionChainId) // Send the transaction const hash = await walletClient.sendTransaction({ @@ -74,7 +81,7 @@ export const useEnsoOrder = ({ setIsExecuting(false) throw err } - }, [getEnsoTransaction, walletClient, publicClient, chainId]) + }, [chainId, executionChainId, getEnsoTransaction, walletClient, publicClient]) const ensoTx = getEnsoTransaction() useEffect(() => { @@ -95,10 +102,10 @@ export const useEnsoOrder = ({ }, [receipt, waitingForTx]) // Create a mock simulate contract result that TxButton can understand - const prepareEnsoOrder: UseSimulateContractReturnType = useMemo(() => { + const prepareEnsoOrder: AppUseSimulateContractReturnType = useMemo(() => { return { data: - enabled && ensoTx + enabled && ensoTx && executionChainId ? { request: { // Standard contract fields for gas estimation @@ -108,7 +115,7 @@ export const useEnsoOrder = ({ args: [] as readonly unknown[], data: ensoTx.data, value: BigInt(ensoTx.value || 0), - chainId, // Use chainId prop (source chain), not ensoTx.chainId which may be undefined + chainId: executionChainId, account: ensoTx.from, // Custom marker to identify this as an Enso order __isEnsoOrder: true, @@ -124,7 +131,7 @@ export const useEnsoOrder = ({ error: null, isError: false, isLoading: isExecuting || waitingForTx, - isSuccess: enabled && !!ensoTx && !isExecuting && !waitingForTx, + isSuccess: enabled && !!ensoTx && !!executionChainId && !isExecuting && !waitingForTx, isFetching: false, isPending: false, isRefetching: false, @@ -132,8 +139,8 @@ export const useEnsoOrder = ({ status: isExecuting ? 'pending' : error ? 'error' : 'success', fetchStatus: 'idle', dataUpdatedAt: Date.now() - } as UseSimulateContractReturnType - }, [enabled, error, isExecuting, executeOrder, ensoTx, txHash, waitingForTx, chainId]) + } as AppUseSimulateContractReturnType + }, [enabled, error, executeOrder, executionChainId, isExecuting, ensoTx, txHash, waitingForTx]) return { prepareEnsoOrder, diff --git a/src/components/pages/vaults/hooks/useTokenAllowance.ts b/src/components/pages/vaults/hooks/useTokenAllowance.ts index 9e5960ce1..c486a192b 100644 --- a/src/components/pages/vaults/hooks/useTokenAllowance.ts +++ b/src/components/pages/vaults/hooks/useTokenAllowance.ts @@ -1,6 +1,6 @@ +import { useBlockNumber, useReadContract } from '@shared/hooks/useAppWagmi' import { useEffect } from 'react' import { type Address, erc4626Abi } from 'viem' -import { useBlockNumber, useReadContract } from 'wagmi' export const useTokenAllowance = ({ account, diff --git a/src/components/pages/vaults/hooks/useTokens.ts b/src/components/pages/vaults/hooks/useTokens.ts index b9aa68467..5858b2c73 100644 --- a/src/components/pages/vaults/hooks/useTokens.ts +++ b/src/components/pages/vaults/hooks/useTokens.ts @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { type Address, erc20Abi, getContract } from 'viem' import { useConfig } from 'wagmi' import { getClient } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' export interface Token { address?: Address @@ -14,11 +15,17 @@ export interface Token { balance: TNormalizedBN } -async function fetchTokenData(config: any, addresses: Address[], chainId: number, account?: Address): Promise { - const client = getClient(config, { chainId }) +async function fetchTokenData( + config: any, + addresses: Address[], + canonicalChainId: number, + executionChainId: number, + account?: Address +): Promise { + const client = getClient(config, { chainId: executionChainId }) if (!client) { - throw new Error(`No client found for chainId ${chainId}`) + throw new Error(`No client found for chainId ${canonicalChainId}`) } const results = await Promise.all( @@ -46,7 +53,7 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number decimals, symbol, name, - chainID: chainId, + chainID: canonicalChainId, balance: toNormalizedBN(balance, decimals) } } catch (error) { @@ -56,7 +63,7 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number decimals: 18, symbol: '???', name: 'Unknown', - chainID: chainId, + chainID: canonicalChainId, balance: toNormalizedBN(0n, 18) } } @@ -78,13 +85,14 @@ async function fetchTokenData(config: any, addresses: Address[], chainId: number ******************************************************************************/ export const useTokens = (addresses: (Address | undefined)[], chainId?: number, account?: Address) => { const config = useConfig() + const executionChainId = resolveExecutionChainId(chainId) const validAddresses = addresses.filter((addr): addr is Address => !isZeroAddress(addr)) const { data, isLoading, refetch } = useQuery({ - queryKey: ['tokens', validAddresses.map((a) => a.toLowerCase()).join('.'), chainId, account], - queryFn: () => fetchTokenData(config, validAddresses, chainId || 1, account), - enabled: validAddresses.length > 0 && !!chainId, + queryKey: ['tokens', validAddresses.map((a) => a.toLowerCase()).join('.'), chainId, executionChainId, account], + queryFn: () => fetchTokenData(config, validAddresses, chainId || 1, executionChainId || 1, account), + enabled: validAddresses.length > 0 && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false diff --git a/src/components/pages/vaults/hooks/useVaultUserData.ts b/src/components/pages/vaults/hooks/useVaultUserData.ts index 75d45d499..4d6c0c15b 100644 --- a/src/components/pages/vaults/hooks/useVaultUserData.ts +++ b/src/components/pages/vaults/hooks/useVaultUserData.ts @@ -5,6 +5,7 @@ import { useCallback, useMemo } from 'react' import { type Address, getContract } from 'viem' import { useConfig } from 'wagmi' import { getClient, readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import { getStakingRedeemableShares, getStakingWithdrawableAssets } from './actions/stakingAdapter' import { type Token, useTokens } from './useTokens' @@ -47,6 +48,7 @@ export const useVaultUserData = ({ account }: UseVaultUserDataParams): VaultUserData => { const config = useConfig() + const executionChainId = resolveExecutionChainId(chainId) // Reuse useTokens for token data + balances const priorityAddresses = useMemo(() => { @@ -63,9 +65,12 @@ export const useVaultUserData = ({ isLoading: isLoadingPPS, refetch: refetchPPS } = useQuery({ - queryKey: ['vaultPricePerShare', vaultAddress?.toLowerCase(), chainId], + queryKey: ['vaultPricePerShare', vaultAddress?.toLowerCase(), chainId, executionChainId], queryFn: async () => { - const client = getClient(config, { chainId }) + if (!executionChainId) { + throw new Error(`No execution chain found for chainId ${chainId}`) + } + const client = getClient(config, { chainId: executionChainId }) if (!client) { throw new Error(`No client found for chainId ${chainId}`) } @@ -76,7 +81,7 @@ export const useVaultUserData = ({ }) return contract.read.pricePerShare() }, - enabled: !!vaultAddress && !!chainId, + enabled: !!vaultAddress && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false @@ -117,6 +122,7 @@ export const useVaultUserData = ({ stakingAddress?.toLowerCase(), account?.toLowerCase(), chainId, + executionChainId, stakingSource || '', stakingShareBalance.toString() ], @@ -135,7 +141,7 @@ export const useVaultUserData = ({ args?: readonly unknown[] }) => readContract(config, { - chainId, + chainId: executionChainId!, address: request.address, abi: request.abi as any, functionName: request.functionName as any, @@ -161,7 +167,7 @@ export const useVaultUserData = ({ return { withdrawableAssets, redeemableShares } }, - enabled: !!stakingAddress && !!account && !!chainId, + enabled: !!stakingAddress && !!account && !!chainId && !!executionChainId, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false diff --git a/src/components/pages/vaults/types/index.ts b/src/components/pages/vaults/types/index.ts index 5766bafac..43a7e271c 100644 --- a/src/components/pages/vaults/types/index.ts +++ b/src/components/pages/vaults/types/index.ts @@ -1,6 +1,6 @@ +import type { AppUseSimulateContractReturnType } from '@shared/hooks/useAppWagmi' import type { TAddress, TNormalizedBN } from '@shared/types' import type { Hex } from 'viem' -import type { UseSimulateContractReturnType } from 'wagmi' export type T = { actions: Actions @@ -9,8 +9,8 @@ export type T = { export type UseStakeReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareStake: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareStake: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -22,7 +22,7 @@ export type UseStakeReturn = T< export type UseUnstakeReturn = T< { - prepareUnstake: UseSimulateContractReturnType + prepareUnstake: AppUseSimulateContractReturnType }, { prepareUnstakeEnabled: boolean @@ -32,8 +32,8 @@ export type UseUnstakeReturn = T< export type UseDepositReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareDeposit: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareDeposit: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -45,7 +45,7 @@ export type UseDepositReturn = T< export type UseWithdrawReturn = T< { - prepareWithdraw: UseSimulateContractReturnType + prepareWithdraw: AppUseSimulateContractReturnType }, { prepareWithdrawEnabled: boolean @@ -57,8 +57,8 @@ export type UseWithdrawReturn = T< // Unified interface for WidgetDepositFinal flows (direct deposit, direct stake, Enso) export type UseWidgetDepositFlowReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareDeposit: UseSimulateContractReturnType + prepareApprove: AppUseSimulateContractReturnType + prepareDeposit: AppUseSimulateContractReturnType }, { prepareApproveEnabled: boolean @@ -83,8 +83,8 @@ export type UseWidgetDepositFlowReturn = T< // Unified interface for WidgetWithdrawFinal flows (direct withdraw, direct unstake, Enso) export type UseWidgetWithdrawFlowReturn = T< { - prepareWithdraw: UseSimulateContractReturnType - prepareApprove?: UseSimulateContractReturnType // Optional: only needed for ENSO withdrawals + prepareWithdraw: AppUseSimulateContractReturnType + prepareApprove?: AppUseSimulateContractReturnType // Optional: only needed for ENSO withdrawals }, { prepareWithdrawEnabled: boolean @@ -118,9 +118,9 @@ export type MigrateRouteType = 'permit' | 'approve' export type UseMigrateFlowReturn = T< { - prepareApprove: UseSimulateContractReturnType - prepareMigrate: UseSimulateContractReturnType - prepareMulticall: UseSimulateContractReturnType // For permit flow: multicall(selfPermit + migrate) + prepareApprove: AppUseSimulateContractReturnType + prepareMigrate: AppUseSimulateContractReturnType + prepareMulticall: AppUseSimulateContractReturnType // For permit flow: multicall(selfPermit + migrate) }, { isAllowanceSufficient: boolean diff --git a/src/components/shared/components/Header.tsx b/src/components/shared/components/Header.tsx index 06bbede8e..680143853 100644 --- a/src/components/shared/components/Header.tsx +++ b/src/components/shared/components/Header.tsx @@ -1,5 +1,6 @@ import { setThemePreference, useThemePreference } from '@hooks/useThemePreference' import { useNotifications } from '@shared/contexts/useNotifications' +import { useTenderlyPanel } from '@shared/contexts/useTenderlyPanel' import useWallet from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' import { IconBurgerPlain } from '@shared/icons/IconBurgerPlain' @@ -14,6 +15,7 @@ import { truncateHex } from '@shared/utils/tools.address' import type { ReactElement } from 'react' import { useMemo, useState } from 'react' import { useLocation } from 'react-router' +import { isTenderlyModeEnabled, tenderlyRuntime } from '@/config/tenderly' import Link from '/src/components/Link' import { AccountDropdown } from './AccountDropdown' import { HeaderNavMenu } from './HeaderNavMenu' @@ -98,6 +100,50 @@ function WalletSelector({ onAccountClick, notificationStatus }: TWalletSelectorP ) } +function TenderlyIndicator(): ReactElement | null { + const { isPanelAvailable, isOpen, togglePanel } = useTenderlyPanel() + + if (!isTenderlyModeEnabled()) { + return null + } + + const configuredMappings = tenderlyRuntime.configuredCanonicalChainIds + .map((canonicalChainId) => { + const executionChainId = tenderlyRuntime.configuredByCanonicalId[canonicalChainId]?.executionChainId + return executionChainId ? `${canonicalChainId} -> ${executionChainId}` : String(canonicalChainId) + }) + .join(', ') + + if (!isPanelAvailable) { + return ( + + {'Tenderly'} + + ) + } + + return ( + + ) +} + function AppHeader(): ReactElement { const location = useLocation() const pathname = location.pathname @@ -144,6 +190,8 @@ function AppHeader(): ReactElement { + + + ) +} + +function PanelShell(props: { + title: string + subtitle?: string + onClose?: () => void + children: ReactElement + className?: string +}): ReactElement { + return ( +
+
+
+

{props.title}

+ {props.subtitle ?

{props.subtitle}

: null} +
+ {props.onClose ? : null} +
+
{props.children}
+
+ ) +} + +function ChainSelector(props: { + availableChains: Array<{ + canonicalChainId: number + canonicalChainName: string + executionChainId: number + hasAdminRpc: boolean + }> + selectedCanonicalChainId?: number + setSelectedCanonicalChainId: (chainId: number) => void +}): ReactElement | null { + if (props.availableChains.length <= 1) { + return null + } + + return ( + + ) +} + +function BaselineGateCard(props: { + pendingAction: string | null + disabled?: boolean + onCreateBaseline: () => Promise +}): ReactElement { + return ( +
+

{'Create a baseline snapshot first'}

+

+ {'All Tenderly mutations stay locked until this chain has a baseline snapshot you can return to.'} +

+
+ void props.onCreateBaseline()} + disabled={props.disabled || props.pendingAction !== null} + variant="primary" + /> +
+
+ ) +} + +function SnapshotList(props: { + pendingAction: string | null + snapshotRecords: TTenderlySnapshotRecord[] + onRevert: (snapshotRecord: TTenderlySnapshotRecord) => Promise +}): ReactElement { + if (props.snapshotRecords.length === 0) { + return

{'No snapshots stored for this Tenderly chain yet.'}

+ } + + return ( +
+ {props.snapshotRecords.map((snapshotRecord) => ( +
+
+
+

{snapshotRecord.label}

+ + {snapshotRecord.kind} + + {snapshotRecord.lastKnownStatus === 'invalid' ? ( + + {'invalid'} + + ) : null} +
+

+ {new Date(snapshotRecord.createdAt).toLocaleString('en-US')} +

+
+ void props.onRevert(snapshotRecord)} + disabled={props.pendingAction !== null || snapshotRecord.lastKnownStatus !== 'valid'} + /> +
+ ))} +
+ ) +} + +function WalletAssetRow(props: { + asset: TTenderlyFundableAsset + isSelected: boolean + onSelect: () => void +}): ReactElement { + return ( + + ) +} + +function useTenderlyControlState() { + const { + status, + selectedCanonicalChainId, + selectedExecutionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + connectedWalletAddress, + pendingAction, + setSelectedCanonicalChainId, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot, + increaseTime, + fundWallet + } = useTenderlyPanel() + const [customAmount, setCustomAmount] = useState('14') + const [customUnit, setCustomUnit] = useState('days') + const [fundSearch, setFundSearch] = useState('') + const [selectedAssetAddress, setSelectedAssetAddress] = useState<`0x${string}` | undefined>(undefined) + const [fundAmount, setFundAmount] = useState('') + const [nativeFundMode, setNativeFundMode] = useState<'add' | 'set'>('add') + const availableChains = useMemo( + () => (status?.configuredChains || []).filter((chain) => chain.hasAdminRpc), + [status?.configuredChains] + ) + const selectedChain = useMemo( + () => availableChains.find((chain) => chain.canonicalChainId === selectedCanonicalChainId), + [availableChains, selectedCanonicalChainId] + ) + const controlsUnlocked = Boolean(baselineSnapshot) + const lastRestorableSnapshot = useMemo(() => getLastRestorableTenderlySnapshot(snapshotRecords), [snapshotRecords]) + + const selectedAssetAddressOrFallback = + selectedAssetAddress && fundableAssets.some((asset) => asset.address === selectedAssetAddress) + ? selectedAssetAddress + : fundableAssets[0]?.address + const selectedAsset = fundableAssets.find((asset) => asset.address === selectedAssetAddressOrFallback) + const filteredAssets = useMemo(() => { + const normalizedSearch = fundSearch.trim().toLowerCase() + if (!normalizedSearch) { + return getDefaultTenderlyFundableAssets(fundableAssets, 14) + } + + return fundableAssets + .filter((asset) => + [asset.symbol, asset.name, asset.address, asset.tokenType].some((value) => + value.toLowerCase().includes(normalizedSearch) + ) + ) + .slice(0, 14) + }, [fundSearch, fundableAssets]) + + const customSeconds = convertTenderlyTimeAmountToSeconds(Number(customAmount), customUnit) + + const handleQuickForwardInput = (amount: number, unit: TTenderlyFastForwardUnit): void => { + const nextInput = addTenderlyTimeIncrement({ + currentAmount: Number(customAmount), + currentUnit: customUnit, + addedAmount: amount, + addedUnit: unit + }) + + setCustomAmount(String(nextInput.amount)) + setCustomUnit(nextInput.unit) + } + + const handleCustomFastForward = async (): Promise => { + if (customSeconds <= 0) { + return + } + + await increaseTime({ seconds: customSeconds, mineBlock: true }) + } + + const handleFundWallet = async (): Promise => { + if (!selectedAsset || !fundAmount) { + return + } + + await fundWallet({ + assetKind: selectedAsset.assetKind, + tokenAddress: selectedAsset.assetKind === 'erc20' ? selectedAsset.address : undefined, + symbol: selectedAsset.symbol, + decimals: selectedAsset.decimals, + amount: fundAmount, + mode: selectedAsset.assetKind === 'native' ? nativeFundMode : 'set' + }) + setFundAmount('') + } + + const handleResetToLast = async (): Promise => { + if (!lastRestorableSnapshot) { + return + } + + await revertToSnapshot(lastRestorableSnapshot) + } + + return { + availableChains, + selectedChain, + selectedCanonicalChainId, + selectedExecutionChainId, + snapshotRecords, + baselineSnapshot, + controlsUnlocked, + lastRestorableSnapshot, + fundableAssets, + filteredAssets, + selectedAsset, + selectedAssetAddress: selectedAssetAddressOrFallback, + setSelectedAssetAddress, + connectedWalletAddress, + pendingAction, + setSelectedCanonicalChainId, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot, + handleResetToLast, + customAmount, + setCustomAmount, + customUnit, + setCustomUnit, + customSeconds, + handleQuickForwardInput, + handleCustomFastForward, + fundSearch, + setFundSearch, + fundAmount, + setFundAmount, + nativeFundMode, + setNativeFundMode, + handleFundWallet + } +} + +type TTenderlyControlState = ReturnType + +function SnapshotPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + execution ${state.selectedExecutionChainId}` + : 'Select a configured Tenderly chain' + } + onClose={props.onClose} + > +
+ {!state.controlsUnlocked ? ( + + ) : ( +
+
+

{'Saved snapshots'}

+

+ {state.lastRestorableSnapshot?.kind === 'snapshot' + ? `Reset currently targets ${state.lastRestorableSnapshot.label}.` + : 'Reset currently falls back to the baseline snapshot.'} +

+
+
+ + void state.createSnapshot()} + disabled={state.pendingAction !== null} + variant="primary" + /> +
+
+ )} + +
+
+ ) +} + +function FastForwardPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + +
+ {!state.controlsUnlocked ? ( + + ) : ( + <> +
+ {QUICK_FORWARD_OPTIONS.map((option) => ( + state.handleQuickForwardInput(option.amount, option.unit)} + /> + ))} +
+
+ state.setCustomAmount(event.target.value)} + className="rounded-full border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder="Amount" + /> + + void state.handleCustomFastForward()} + disabled={state.pendingAction !== null || state.customSeconds <= 0} + variant="primary" + /> +
+

+ {state.customSeconds > 0 + ? `Ready to move Tenderly forward by ${state.customSeconds.toLocaleString('en-US')} seconds and mine one block.` + : 'Enter a positive amount to fast-forward Tenderly.'} +

+ + )} +
+
+ ) +} + +function WalletFaucetPanel(props: { state: TTenderlyControlState; onClose?: () => void }): ReactElement { + const { state } = props + + return ( + +
+ {!state.controlsUnlocked ? ( + + ) : ( + <> + state.setFundSearch(event.target.value)} + className="w-full rounded-2xl border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder="Search common Yearn and Kong assets" + /> +
+ {state.filteredAssets.length === 0 ? ( +

{'No matching assets'}

+ ) : ( + state.filteredAssets.map((asset) => ( + state.setSelectedAssetAddress(asset.address)} + /> + )) + )} +
+
+

{'Selected asset'}

+ {state.selectedAsset ? ( + <> +

+ {`${state.selectedAsset.symbol} · ${state.selectedAsset.name}`} +

+

+ {state.selectedAsset.address} +

+ + ) : ( +

{'Choose an asset to fund the connected wallet.'}

+ )} +
+
+ state.setFundAmount(event.target.value)} + className="rounded-full border border-border bg-surface px-3 py-2 text-sm text-text-primary" + placeholder={state.selectedAsset ? `Amount in ${state.selectedAsset.symbol}` : 'Amount'} + /> + {state.selectedAsset?.assetKind === 'native' ? ( + + ) : null} + void state.handleFundWallet()} + disabled={ + state.pendingAction !== null || + !state.connectedWalletAddress || + !state.selectedAsset || + !state.fundAmount + } + variant="primary" + /> +
+ + )} +
+
+ ) +} + +function MobileTenderlyPanelContent(props: { state: TTenderlyControlState }): ReactElement { + const { state } = props + + return ( +
+
+
+

{'Tenderly Control Panel'}

+

+ {state.selectedCanonicalChainId && state.selectedExecutionChainId + ? `Canonical ${state.selectedCanonicalChainId} -> execution ${state.selectedExecutionChainId}` + : 'Select a configured Tenderly chain'} +

+
+
+ + {state.availableChains.length <= 1 ? ( + + {state.selectedChain?.canonicalChainName || 'No Tenderly chains'} + + ) : null} +
+
+ + {!state.controlsUnlocked ? ( + + ) : ( + <> +
+
+ void state.handleResetToLast()} + disabled={state.pendingAction !== null || !state.lastRestorableSnapshot} + variant="primary" + /> + void state.createSnapshot()} + disabled={state.pendingAction !== null} + /> + +
+
+ + + + + + )} +
+ ) +} + +function DesktopTenderlyPanel(props: { state: TTenderlyControlState }): ReactElement { + const { state } = props + const [activePanel, setActivePanel] = useState(null) + const panelRootRef = useRef(null) + + useEffect(() => { + if (!state.controlsUnlocked) { + setActivePanel('snapshots') + } + }, [state.controlsUnlocked]) + + useEffect(() => { + if (!activePanel) { + return + } + + const handlePointerDown = (event: PointerEvent): void => { + const target = event.target + if (!(target instanceof Node)) { + return + } + + if (!panelRootRef.current?.contains(target)) { + setActivePanel(null) + } + } + + document.addEventListener('pointerdown', handlePointerDown) + return () => document.removeEventListener('pointerdown', handlePointerDown) + }, [activePanel]) + + const selectedChainLabel = state.selectedChain?.canonicalChainName || 'Tenderly chain' + const statusLabel = state.selectedExecutionChainId + ? `${selectedChainLabel} · execution ${state.selectedExecutionChainId}` + : selectedChainLabel + + return ( +
+
+ {activePanel === 'snapshots' ? ( + setActivePanel(null)} /> + ) : activePanel === 'faucet' ? ( + setActivePanel(null)} /> + ) : activePanel === 'fast-forward' ? ( + setActivePanel(null)} /> + ) : null} + +
+
+

{statusLabel}

+ +
+
+ setActivePanel((current) => (current === 'snapshots' ? null : 'snapshots'))} + isActive={activePanel === 'snapshots'} + /> + { + setActivePanel(null) + void state.handleResetToLast() + }} + disabled={state.pendingAction !== null || !state.controlsUnlocked || !state.lastRestorableSnapshot} + variant="primary" + /> + setActivePanel((current) => (current === 'faucet' ? null : 'faucet'))} + disabled={!state.controlsUnlocked} + isActive={activePanel === 'faucet'} + /> + setActivePanel((current) => (current === 'fast-forward' ? null : 'fast-forward'))} + disabled={!state.controlsUnlocked} + isActive={activePanel === 'fast-forward'} + /> +
+
+
+
+ ) +} + +function TenderlyControlPanelBody(props: { isOpen: boolean; closePanel: () => void }): ReactElement { + const state = useTenderlyControlState() + const isDesktop = useMediaQuery('(min-width: 768px)', { initializeWithValue: true }) ?? false + + if (isDesktop) { + return + } + + return ( + + + + ) +} + +export function TenderlyControlPanel(): ReactElement | null { + const { isTenderlyMode, isPanelAvailable, isOpen, closePanel } = useTenderlyPanel() + + if (!isTenderlyMode || !isPanelAvailable || !isOpen) { + return null + } + + return +} diff --git a/src/components/shared/contexts/useNotificationsActions.tsx b/src/components/shared/contexts/useNotificationsActions.tsx index d917616e6..9942b7d6d 100644 --- a/src/components/shared/contexts/useNotificationsActions.tsx +++ b/src/components/shared/contexts/useNotificationsActions.tsx @@ -30,6 +30,7 @@ export const WithNotificationsActions = ({ children }: { children: React.ReactEl fromAddress: toAddress(params.fromAddress), fromTokenName: params.fromSymbol, chainId: params.fromChainId, + executionChainId: params.executionChainId ?? params.fromChainId, toAddress: params.toAddress ? toAddress(params.toAddress) : undefined, toTokenName: params.toSymbol, toAmount: params.toAmount, diff --git a/src/components/shared/contexts/useTenderlyPanel.tsx b/src/components/shared/contexts/useTenderlyPanel.tsx new file mode 100644 index 000000000..68cdb4134 --- /dev/null +++ b/src/components/shared/contexts/useTenderlyPanel.tsx @@ -0,0 +1,514 @@ +import { useLocalStorageValue } from '@react-hookz/web' +import { toast } from '@shared/components/yToast' +import { useWallet } from '@shared/contexts/useWallet' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { useTokenList } from '@shared/contexts/WithTokenList' +import type { + TTenderlyFundableAsset, + TTenderlyFundRequest, + TTenderlyIncreaseTimeRequest, + TTenderlyPanelStatus, + TTenderlySnapshotRecord, + TTenderlySnapshotRequest +} from '@shared/types/tenderly' +import { + buildTenderlyFundableAssets, + clearTenderlySnapshotBucket, + getTenderlySnapshotBucketKey, + getValidBaselineSnapshot, + markTenderlySnapshotInvalid, + reconcileTenderlySnapshotStorageAfterRevert, + resolveDefaultTenderlyCanonicalChainId, + sortTenderlySnapshotRecords, + TENDERLY_SNAPSHOT_STORAGE_KEY, + type TTenderlySnapshotStorage, + upsertTenderlySnapshotRecord +} from '@shared/utils/tenderlyPanel' +import { useQueryClient } from '@tanstack/react-query' +import { + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react' +import { isTenderlyModeEnabled } from '@/config/tenderly' +import { useChains } from '@/context/chainsContext' + +type TTenderlyPanelContext = { + isTenderlyMode: boolean + isStatusLoading: boolean + isPanelAvailable: boolean + isOpen: boolean + status?: TTenderlyPanelStatus + selectedCanonicalChainId?: number + selectedExecutionChainId?: number + snapshotRecords: TTenderlySnapshotRecord[] + baselineSnapshot?: TTenderlySnapshotRecord + fundableAssets: TTenderlyFundableAsset[] + connectedWalletAddress?: `0x${string}` + pendingAction: string | null + openPanel: () => void + closePanel: () => void + togglePanel: () => void + setSelectedCanonicalChainId: (chainId: number) => void + refetchStatus: () => Promise + createBaselineSnapshot: () => Promise + createSnapshot: () => Promise + clearSnapshotHistory: () => void + revertToSnapshot: (snapshotRecord: TTenderlySnapshotRecord) => Promise + increaseTime: (params: Omit) => Promise + fundWallet: (params: Omit) => Promise +} + +const TenderlyPanelContext = createContext({ + isTenderlyMode: false, + isStatusLoading: false, + isPanelAvailable: false, + isOpen: false, + snapshotRecords: [], + fundableAssets: [], + pendingAction: null, + openPanel: (): void => undefined, + closePanel: (): void => undefined, + togglePanel: (): void => undefined, + setSelectedCanonicalChainId: (): void => undefined, + refetchStatus: async (): Promise => undefined, + createBaselineSnapshot: async (): Promise => undefined, + createSnapshot: async (): Promise => undefined, + clearSnapshotHistory: (): void => undefined, + revertToSnapshot: async (): Promise => undefined, + increaseTime: async (): Promise => undefined, + fundWallet: async (): Promise => undefined +}) + +async function fetchTenderlyApi( + path: string, + options?: { + method?: 'GET' | 'POST' + body?: TRequest + } +): Promise { + const response = await fetch(path, { + method: options?.method || 'GET', + headers: options?.body ? { 'content-type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined + }) + const payload = (await response.json().catch(() => undefined)) as { error?: string } & TResponse + + if (!response.ok) { + throw new Error(payload?.error || `Tenderly API request failed with status ${response.status}`) + } + + return payload +} + +export function TenderlyPanelProvider({ children }: { children: ReactNode }): ReactElement { + const queryClient = useQueryClient() + const { chainIdIntent } = useChains() + const { chainID, address } = useWeb3() + const { onRefresh } = useWallet() + const { allVaults, enableVaultListFetch } = useYearn() + const { tokenLists } = useTokenList() + const [status, setStatus] = useState(undefined) + const [isStatusLoading, setIsStatusLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [selectedCanonicalChainId, setSelectedCanonicalChainIdState] = useState(undefined) + const [pendingAction, setPendingAction] = useState(null) + const { value: snapshotStorageValue, set: setSnapshotStorage } = useLocalStorageValue( + TENDERLY_SNAPSHOT_STORAGE_KEY, + { + defaultValue: {} + } + ) + const snapshotStorage = snapshotStorageValue || {} + const isTenderlyMode = isTenderlyModeEnabled() + + const refetchStatus = useCallback(async (): Promise => { + if (!isTenderlyMode) { + setStatus(undefined) + return + } + + setIsStatusLoading(true) + try { + const nextStatus = await fetchTenderlyApi('/api/tenderly/status') + setStatus(nextStatus) + } catch (error) { + console.error('Failed to fetch Tenderly status:', error) + setStatus(undefined) + } finally { + setIsStatusLoading(false) + } + }, [isTenderlyMode]) + + useEffect(() => { + void refetchStatus() + }, [refetchStatus]) + + useEffect(() => { + if (isOpen) { + enableVaultListFetch() + } + }, [enableVaultListFetch, isOpen]) + + const availableConfiguredChains = useMemo( + () => (status?.configuredChains || []).filter((chain) => chain.hasAdminRpc), + [status?.configuredChains] + ) + + useEffect(() => { + const defaultCanonicalChainId = resolveDefaultTenderlyCanonicalChainId(availableConfiguredChains, [ + chainID, + chainIdIntent + ]) + const canKeepSelectedChain = availableConfiguredChains.some( + (chain) => chain.canonicalChainId === selectedCanonicalChainId + ) + + if (canKeepSelectedChain) { + return + } + + setSelectedCanonicalChainIdState(defaultCanonicalChainId) + }, [availableConfiguredChains, chainID, chainIdIntent, selectedCanonicalChainId]) + + const selectedChain = useMemo( + () => availableConfiguredChains.find((chain) => chain.canonicalChainId === selectedCanonicalChainId), + [availableConfiguredChains, selectedCanonicalChainId] + ) + const snapshotBucketKey = + selectedChain && selectedCanonicalChainId !== undefined + ? getTenderlySnapshotBucketKey(selectedCanonicalChainId, selectedChain.executionChainId) + : undefined + const snapshotRecords = useMemo( + () => (snapshotBucketKey ? sortTenderlySnapshotRecords(snapshotStorage[snapshotBucketKey] || []) : []), + [snapshotBucketKey, snapshotStorage] + ) + const baselineSnapshot = useMemo(() => getValidBaselineSnapshot(snapshotRecords), [snapshotRecords]) + const fundableAssets = useMemo( + () => + selectedCanonicalChainId + ? buildTenderlyFundableAssets({ + chainId: selectedCanonicalChainId, + tokenLists, + allVaults + }) + : [], + [allVaults, selectedCanonicalChainId, tokenLists] + ) + + const isPanelAvailable = isTenderlyMode && availableConfiguredChains.length > 0 + + const refreshTenderlyDependentState = useCallback(async (): Promise => { + await queryClient.invalidateQueries() + if (address) { + await onRefresh().catch((error) => { + console.error('Failed to refresh wallet balances after Tenderly action:', error) + }) + } + }, [address, onRefresh, queryClient]) + + const updateSnapshotStorage = useCallback( + (updater: (snapshotStorage: TTenderlySnapshotStorage) => TTenderlySnapshotStorage): void => { + setSnapshotStorage(updater(snapshotStorage)) + }, + [setSnapshotStorage, snapshotStorage] + ) + + const requestSnapshotRecord = useCallback( + async (request: TTenderlySnapshotRequest): Promise => + await fetchTenderlyApi('/api/tenderly/snapshot', { + method: 'POST', + body: request + }), + [] + ) + + const createSnapshotWithKind = useCallback( + async (isBaseline: boolean): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + + setPendingAction(isBaseline ? 'create-baseline' : 'create-snapshot') + try { + const snapshotRecord = await requestSnapshotRecord({ + canonicalChainId: selectedCanonicalChainId, + isBaseline + }) + updateSnapshotStorage((currentStorage) => upsertTenderlySnapshotRecord(currentStorage, snapshotRecord)) + toast({ + content: isBaseline ? 'Baseline snapshot created' : 'Snapshot created', + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [requestSnapshotRecord, selectedCanonicalChainId, updateSnapshotStorage] + ) + + const revertToSnapshot = useCallback( + async (snapshotRecord: TTenderlySnapshotRecord): Promise => { + setPendingAction('revert-snapshot') + try { + try { + await fetchTenderlyApi('/api/tenderly/revert', { + method: 'POST', + body: { + canonicalChainId: snapshotRecord.canonicalChainId, + snapshotId: snapshotRecord.snapshotId + } + }) + } catch (error) { + updateSnapshotStorage((currentStorage) => + markTenderlySnapshotInvalid(currentStorage, { + canonicalChainId: snapshotRecord.canonicalChainId, + executionChainId: snapshotRecord.executionChainId, + snapshotId: snapshotRecord.snapshotId + }) + ) + throw error + } + + let replacementSnapshotRecord: TTenderlySnapshotRecord | undefined + try { + replacementSnapshotRecord = await requestSnapshotRecord({ + canonicalChainId: snapshotRecord.canonicalChainId, + isBaseline: snapshotRecord.kind === 'baseline', + label: snapshotRecord.label + }) + } catch (error) { + console.error('Failed to create replacement Tenderly snapshot after revert:', error) + } + + updateSnapshotStorage((currentStorage) => + reconcileTenderlySnapshotStorageAfterRevert(currentStorage, { + revertedSnapshotRecord: snapshotRecord, + replacementSnapshotRecord + }) + ) + + try { + await refreshTenderlyDependentState() + } catch (error) { + console.error('Failed to refresh state after Tenderly revert:', error) + } + + toast({ + content: replacementSnapshotRecord + ? snapshotRecord.kind === 'baseline' + ? 'Reset to baseline complete' + : 'Snapshot revert complete' + : snapshotRecord.kind === 'baseline' + ? 'Reset to baseline complete, but the baseline was consumed. Create a new baseline snapshot before resetting again.' + : 'Snapshot revert complete, but that snapshot was consumed. Create a new snapshot if you want to reuse it.', + type: replacementSnapshotRecord ? 'success' : 'warning' + }) + } finally { + setPendingAction(null) + } + }, + [refreshTenderlyDependentState, requestSnapshotRecord, updateSnapshotStorage] + ) + + const increaseTime = useCallback( + async (params: Omit): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + + setPendingAction('increase-time') + try { + await fetchTenderlyApi('/api/tenderly/increase-time', { + method: 'POST', + body: { + canonicalChainId: selectedCanonicalChainId, + seconds: params.seconds, + mineBlock: params.mineBlock + } + }) + await refreshTenderlyDependentState() + toast({ + content: `Fast-forwarded Tenderly by ${params.seconds} seconds`, + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [refreshTenderlyDependentState, selectedCanonicalChainId] + ) + + const fundWallet = useCallback( + async (params: Omit): Promise => { + if (!selectedCanonicalChainId) { + throw new Error('No Tenderly chain selected') + } + if (!address) { + throw new Error('Connect a wallet before using the Tenderly faucet') + } + + setPendingAction('fund-wallet') + try { + await fetchTenderlyApi('/api/tenderly/fund', { + method: 'POST', + body: { + canonicalChainId: selectedCanonicalChainId, + walletAddress: address, + ...params + } + }) + await refreshTenderlyDependentState() + toast({ + content: `Funded ${address}`, + type: 'success' + }) + } finally { + setPendingAction(null) + } + }, + [address, refreshTenderlyDependentState, selectedCanonicalChainId] + ) + + const createBaselineSnapshot = useCallback(async (): Promise => { + try { + await createSnapshotWithKind(true) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to create baseline snapshot', + type: 'error' + }) + } + }, [createSnapshotWithKind]) + + const createSnapshot = useCallback(async (): Promise => { + try { + await createSnapshotWithKind(false) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to create snapshot', + type: 'error' + }) + } + }, [createSnapshotWithKind]) + + const clearSnapshotHistory = useCallback((): void => { + if (!selectedCanonicalChainId || !selectedChain?.executionChainId) { + return + } + + updateSnapshotStorage((currentStorage) => + clearTenderlySnapshotBucket(currentStorage, { + canonicalChainId: selectedCanonicalChainId, + executionChainId: selectedChain.executionChainId + }) + ) + toast({ + content: 'Cleared local Tenderly snapshot history for this chain', + type: 'success' + }) + }, [selectedCanonicalChainId, selectedChain?.executionChainId, updateSnapshotStorage]) + + const revertToSnapshotWithToast = useCallback( + async (snapshotRecord: TTenderlySnapshotRecord): Promise => { + try { + await revertToSnapshot(snapshotRecord) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to revert snapshot', + type: 'error' + }) + } + }, + [revertToSnapshot] + ) + + const increaseTimeWithToast = useCallback( + async (params: Omit): Promise => { + try { + await increaseTime(params) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to fast-forward Tenderly', + type: 'error' + }) + } + }, + [increaseTime] + ) + + const fundWalletWithToast = useCallback( + async (params: Omit): Promise => { + try { + await fundWallet(params) + } catch (error) { + toast({ + content: error instanceof Error ? error.message : 'Failed to fund wallet on Tenderly', + type: 'error' + }) + } + }, + [fundWallet] + ) + + const contextValue = useMemo( + (): TTenderlyPanelContext => ({ + isTenderlyMode, + isStatusLoading, + isPanelAvailable, + isOpen, + status, + selectedCanonicalChainId, + selectedExecutionChainId: selectedChain?.executionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + connectedWalletAddress: address, + pendingAction, + openPanel: (): void => setIsOpen(true), + closePanel: (): void => setIsOpen(false), + togglePanel: (): void => setIsOpen((current) => !current), + setSelectedCanonicalChainId: setSelectedCanonicalChainIdState, + refetchStatus, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshot: revertToSnapshotWithToast, + increaseTime: increaseTimeWithToast, + fundWallet: fundWalletWithToast + }), + [ + isTenderlyMode, + isStatusLoading, + isPanelAvailable, + isOpen, + status, + selectedCanonicalChainId, + selectedChain?.executionChainId, + snapshotRecords, + baselineSnapshot, + fundableAssets, + address, + pendingAction, + refetchStatus, + createBaselineSnapshot, + createSnapshot, + clearSnapshotHistory, + revertToSnapshotWithToast, + increaseTimeWithToast, + fundWalletWithToast + ] + ) + + return {children} +} + +export function useTenderlyPanel(): TTenderlyPanelContext { + return useContext(TenderlyPanelContext) +} diff --git a/src/components/shared/contexts/useWeb3.tsx b/src/components/shared/contexts/useWeb3.tsx index abc1aed5d..ecd31b20c 100755 --- a/src/components/shared/contexts/useWeb3.tsx +++ b/src/components/shared/contexts/useWeb3.tsx @@ -9,6 +9,7 @@ import type { ReactElement } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { mainnet } from 'viem/chains' import { useAccount, useConnect, useDisconnect, useEnsName } from 'wagmi' +import { resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' type TWeb3Context = { address: TAddress | undefined @@ -58,7 +59,7 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement const previousChainIDRef = useRef(undefined) const hasUserRequestedConnectionRef = useRef(false) - const chainID = chain?.id ?? 1 + const chainID = resolveConnectedCanonicalChainId(chain?.id) ?? (isConnected ? 0 : 1) useEffect(() => { if (!wasConnectedRef.current && isConnected && hasUserRequestedConnectionRef.current) { @@ -105,7 +106,7 @@ export const Web3ContextApp = (props: { children: ReactElement }): ReactElement hasUserRequestedConnectionRef.current = true await connectAsync({ connector: ledgerConnector, - chainId: chainID + chainId: resolveExecutionChainId(chainID) ?? chainID }) return } diff --git a/src/components/shared/hooks/useAppWagmi.ts b/src/components/shared/hooks/useAppWagmi.ts new file mode 100644 index 000000000..a311258de --- /dev/null +++ b/src/components/shared/hooks/useAppWagmi.ts @@ -0,0 +1,205 @@ +import type { SimulateContractData } from '@wagmi/core/query' +import { useCallback } from 'react' +import type { Abi, ContractFunctionArgs, ContractFunctionName } from 'viem' +import type { + Config, + UseBlockNumberParameters, + UseBlockNumberReturnType, + UsePublicClientParameters, + UsePublicClientReturnType, + UseReadContractParameters, + UseReadContractReturnType, + UseSimulateContractParameters, + UseSimulateContractReturnType, + UseSwitchChainReturnType, + UseWaitForTransactionReceiptParameters, + UseWaitForTransactionReceiptReturnType +} from 'wagmi' +import { + useBlockNumber as useWagmiBlockNumber, + useChainId as useWagmiChainId, + usePublicClient as useWagmiPublicClient, + useReadContract as useWagmiReadContract, + useSimulateContract as useWagmiSimulateContract, + useSwitchChain as useWagmiSwitchChain, + useWaitForTransactionReceipt as useWagmiWaitForTransactionReceipt +} from 'wagmi' +import { isTenderlyModeEnabled, resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' + +const DISABLED_CHAIN_ID = Number.MAX_SAFE_INTEGER + +function resolveHookChainId(chainId?: number): number | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const resolvedChainId = resolveExecutionChainId(chainId) + if (resolvedChainId !== undefined) { + return resolvedChainId + } + + return isTenderlyModeEnabled() ? DISABLED_CHAIN_ID : chainId +} + +function isUnsupportedRequestedChain(chainId?: number): boolean { + return Number.isInteger(chainId) && resolveExecutionChainId(chainId) === undefined +} + +export type AppUseSimulateContractReturnType = { + data?: { + request?: { + chainId?: number + address?: unknown + functionName?: unknown + args?: readonly unknown[] + [key: string]: unknown + } + result?: unknown + [key: string]: unknown + } + error?: unknown + isError: boolean + isFetching: boolean + isLoading: boolean + isPending?: boolean + isRefetching?: boolean + isSuccess: boolean + refetch?: () => Promise | unknown + status: string + [key: string]: unknown +} + +export function useChainId(): number { + const rawChainId = useWagmiChainId() + return resolveConnectedCanonicalChainId(rawChainId) ?? (isTenderlyModeEnabled() ? 0 : rawChainId) +} + +export function useSwitchChain(): UseSwitchChainReturnType { + const wagmiSwitchChain = useWagmiSwitchChain() + + const switchChain = useCallback( + (parameters) => { + const executionChainId = resolveExecutionChainId(parameters.chainId) + if (executionChainId === undefined) { + throw new Error(`Chain ${parameters.chainId} is not enabled for execution`) + } + + return wagmiSwitchChain.switchChain?.({ ...parameters, chainId: executionChainId }) + }, + [wagmiSwitchChain] + ) + + const switchChainAsync = useCallback( + async (parameters) => { + const executionChainId = resolveExecutionChainId(parameters.chainId) + if (executionChainId === undefined) { + throw new Error(`Chain ${parameters.chainId} is not enabled for execution`) + } + + return await wagmiSwitchChain.switchChainAsync?.({ ...parameters, chainId: executionChainId }) + }, + [wagmiSwitchChain] + ) + + return { + ...wagmiSwitchChain, + switchChain, + switchChainAsync + } +} + +export function useReadContract< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName = ContractFunctionName, + args extends ContractFunctionArgs = ContractFunctionArgs< + abi, + 'pure' | 'view', + functionName + > +>(parameters: UseReadContractParameters): UseReadContractReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters.chainId) + + return useWagmiReadContract({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId), + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as UseReadContractParameters) +} + +export function useSimulateContract< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName = ContractFunctionName< + abi, + 'nonpayable' | 'payable' + >, + args extends ContractFunctionArgs = ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, + chainId extends Config['chains'][number]['id'] | undefined = undefined, + selectData = SimulateContractData +>( + parameters?: UseSimulateContractParameters +): UseSimulateContractReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters?.chainId) + const resolvedParameters = + parameters === undefined + ? undefined + : ({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId) as chainId, + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as any) + + return useWagmiSimulateContract(resolvedParameters) +} + +export function useBlockNumber( + parameters?: UseBlockNumberParameters +): UseBlockNumberReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters?.chainId) + + return useWagmiBlockNumber({ + ...(parameters || {}), + chainId: resolveHookChainId(parameters?.chainId) as chainId, + query: { + ...(parameters?.query || {}), + enabled: !unsupportedRequestedChain && (parameters?.query?.enabled ?? true) + }, + watch: !unsupportedRequestedChain && Boolean(parameters?.watch) + } as UseBlockNumberParameters) +} + +export function useWaitForTransactionReceipt< + chainId extends Config['chains'][number]['id'] = Config['chains'][number]['id'] +>( + parameters: UseWaitForTransactionReceiptParameters +): UseWaitForTransactionReceiptReturnType { + const unsupportedRequestedChain = isUnsupportedRequestedChain(parameters.chainId) + + return useWagmiWaitForTransactionReceipt({ + ...parameters, + chainId: resolveHookChainId(parameters.chainId) as chainId, + hash: unsupportedRequestedChain ? undefined : parameters.hash, + query: { + ...(parameters.query || {}), + enabled: !unsupportedRequestedChain && (parameters.query?.enabled ?? true) + } + } as UseWaitForTransactionReceiptParameters) +} + +export function usePublicClient( + parameters?: UsePublicClientParameters +): UsePublicClientReturnType { + return useWagmiPublicClient({ + ...(parameters || {}), + chainId: resolveHookChainId(parameters?.chainId) as chainId + } as UsePublicClientParameters) +} diff --git a/src/components/shared/hooks/useBalances.multichains.ts b/src/components/shared/hooks/useBalances.multichains.ts index 0a273ac2e..40a28a084 100644 --- a/src/components/shared/hooks/useBalances.multichains.ts +++ b/src/components/shared/hooks/useBalances.multichains.ts @@ -3,6 +3,7 @@ import type { DependencyList } from 'react' import { erc20Abi, type MulticallParameters } from 'viem' import type { Connector } from 'wagmi' import { multicall } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../types/address' import type { TChainTokens, TDefaultStatus, TDict, TNDict, TToken } from '../types/mixed' import { ETH_TOKEN_ADDRESS, MULTICALL3_ADDRESS } from '../utils/constants' @@ -62,12 +63,17 @@ export type TUseBalancesRes = { type TUpdates = TDict const TOKEN_UPDATE: TUpdates = {} +function getTokenUpdateKey(chainID: number, executionChainId: number | undefined, address: TAddress): string { + return `${chainID}:${executionChainId ?? 'none'}/${toAddress(address)}` +} + export function hasPositiveCachedBalance(chainID: number, address: TAddress, ownerAddress?: TAddress): boolean { if (!ownerAddress || isZeroAddress(ownerAddress)) { return false } - const tokenUpdateInfo = TOKEN_UPDATE[`${chainID}/${toAddress(address)}`] + const executionChainId = resolveExecutionChainId(chainID) + const tokenUpdateInfo = TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, address)] if (!tokenUpdateInfo) { return false } @@ -81,6 +87,11 @@ export async function performCall( tokens: TUseBalancesTokens[], ownerAddress: TAddress ): Promise<[TDict, Error | undefined]> { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined) { + return [{}, new Error(`Chain ${chainID} is not enabled for execution`)] + } + type TMulticallResult = | { error?: undefined; result: never; status: 'success' } | { error: Error; result?: undefined; status: 'failure' } @@ -89,11 +100,11 @@ export async function performCall( try { const results = await multicall(retrieveConfig(), { contracts: chunckCalls as never[], - chainId: chainID + chainId: executionChainId }) return { results } } catch (error) { - console.error(`Failed to trigger multicall on chain ${chainID}`, error) + console.error(`Failed to trigger multicall on chain ${executionChainId}`, error) return { results: [], error: error as Error } } })() @@ -176,7 +187,7 @@ export async function performCall( _data[toAddress(address)].decimals = 18 } - TOKEN_UPDATE[`${chainID}/${toAddress(address)}`] = { + TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, address)] = { ..._data[toAddress(address)], owner: toAddress(ownerAddress), lastUpdate: Date.now() @@ -198,10 +209,11 @@ export async function getBalances( shouldForceFetch = false ): Promise<[TDict, Error | undefined, TBalanceFetchStats]> { const ownerAddress = address + const executionChainId = resolveExecutionChainId(chainID) const cachedResults: TDict = {} for (const element of tokens) { - const tokenUpdateInfo = TOKEN_UPDATE[`${chainID}/${toAddress(element.address)}`] + const tokenUpdateInfo = TOKEN_UPDATE[getTokenUpdateKey(chainID, executionChainId, element.address)] if (tokenUpdateInfo?.lastUpdate && Date.now() - tokenUpdateInfo?.lastUpdate < 60_000 && !shouldForceFetch) { if (toAddress(tokenUpdateInfo.owner) === toAddress(ownerAddress)) { cachedResults[toAddress(element.address)] = tokenUpdateInfo diff --git a/src/components/shared/hooks/useBalancesCombined.ts b/src/components/shared/hooks/useBalancesCombined.ts index 81e8f51be..86e3b2c3a 100644 --- a/src/components/shared/hooks/useBalancesCombined.ts +++ b/src/components/shared/hooks/useBalancesCombined.ts @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' +import { getTenderlyBackedCanonicalChainIds, resolveExecutionChainId } from '@/config/tenderly' import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { toAddress } from '../utils/tools.address' @@ -58,13 +59,17 @@ function mergeBalanceSources(...sources: TChainTokens[]): TChainTokens { export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { const { address: userAddress } = useWeb3() const queryClient = useQueryClient() + const ensoUnsupportedNetworks = useMemo( + () => [...new Set([...ENSO_UNSUPPORTED_NETWORKS, ...getTenderlyBackedCanonicalChainIds()])], + [] + ) const tokens = useMemo(() => (userAddress ? props?.tokens || [] : []), [props?.tokens, userAddress]) // Split tokens into Enso-supported and multicall-required groups const { ensoTokens, multicallTokens: requiredMulticallTokens } = useMemo(() => { - return partitionTokensByBalanceSource(tokens, ENSO_UNSUPPORTED_NETWORKS) - }, [tokens]) + return partitionTokensByBalanceSource(tokens, ensoUnsupportedNetworks) + }, [ensoUnsupportedNetworks, tokens]) // Fetch from Enso for supported chains const { @@ -250,12 +255,13 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { for (const [chainIdStr, chainTokens] of Object.entries(tokensByChain)) { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) const freshBalances = await fetchTokenBalances(chainId, userAddress, chainTokens, true) // Update multicall query cache const allQueries = queryClient.getQueriesData>({ - queryKey: balanceQueryKeys.byChainAndUser(chainId, userAddress), + queryKey: balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), exact: false }) @@ -277,7 +283,7 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { const mergedEnsoData = { ...currentEnsoData } for (const [chainIdStr, tokens] of Object.entries(updatedBalances)) { const chainId = Number(chainIdStr) - if (!ENSO_UNSUPPORTED_NETWORKS.includes(chainId)) { + if (!ensoUnsupportedNetworks.includes(chainId)) { if (!mergedEnsoData[chainId]) { mergedEnsoData[chainId] = {} } @@ -289,7 +295,7 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { return updatedBalances }, - [queryClient, userAddress] + [ensoUnsupportedNetworks, queryClient, userAddress] ) const status = useMemo((): 'error' | 'loading' | 'success' | 'unknown' => { diff --git a/src/components/shared/hooks/useBalancesQueries.ts b/src/components/shared/hooks/useBalancesQueries.ts index 4c1b0b168..21b966ef8 100644 --- a/src/components/shared/hooks/useBalancesQueries.ts +++ b/src/components/shared/hooks/useBalancesQueries.ts @@ -1,6 +1,7 @@ import { useDeepCompareMemo } from '@react-hookz/web' import { type UseQueryOptions, useQueries } from '@tanstack/react-query' import { useCallback, useMemo, useRef } from 'react' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../types/address' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { isZeroAddress } from '../utils/tools.is' @@ -23,9 +24,10 @@ function buildBalanceQueryOptions( ): TBalanceQueryOptions[] { return Object.entries(tokensByChain).map(([chainIdStr, chainTokens]) => { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) const config = getChainConfig(chainId) const tokenAddresses = chainTokens.map((t) => t.address) - const queryKey = balanceQueryKeys.byTokens(chainId, userAddress, tokenAddresses) + const queryKey = balanceQueryKeys.byTokens(chainId, executionChainId, userAddress, tokenAddresses) return { queryKey, diff --git a/src/components/shared/hooks/useBalancesQuery.test.ts b/src/components/shared/hooks/useBalancesQuery.test.ts new file mode 100644 index 000000000..d223260b9 --- /dev/null +++ b/src/components/shared/hooks/useBalancesQuery.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('balanceQueryKeys', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('../utils/tools.address') + }) + + it('separates cache keys by execution chain id', async () => { + vi.doMock('../utils/tools.address', () => ({ + toAddress: (value?: string) => value?.toLowerCase() ?? '0x0' + })) + + const { balanceQueryKeys } = await import('./useBalancesQuery') + + expect(balanceQueryKeys.byTokens(1, 73571, '0x123', ['0xabc'])).not.toEqual( + balanceQueryKeys.byTokens(1, 73572, '0x123', ['0xabc']) + ) + }) + + it('keeps canonical chain id in the key for display-level grouping', async () => { + vi.doMock('../utils/tools.address', () => ({ + toAddress: (value?: string) => value?.toLowerCase() ?? '0x0' + })) + + const { balanceQueryKeys } = await import('./useBalancesQuery') + + expect(balanceQueryKeys.byChain(1, 73571)).toEqual(['balances', 'chain', 1, 'execution', 73571]) + }) +}) diff --git a/src/components/shared/hooks/useBalancesQuery.ts b/src/components/shared/hooks/useBalancesQuery.ts index 332e36426..7bccddae1 100644 --- a/src/components/shared/hooks/useBalancesQuery.ts +++ b/src/components/shared/hooks/useBalancesQuery.ts @@ -4,16 +4,35 @@ import { toAddress } from '../utils/tools.address' /******************************************************************************* ** Query key factory for consistent cache key generation ******************************************************************************/ +function toExecutionKey(executionChainId?: number): number | 'none' { + return Number.isInteger(executionChainId) ? (executionChainId as number) : 'none' +} + export const balanceQueryKeys = { all: ['balances'] as const, - byChain: (chainId: number) => [...balanceQueryKeys.all, 'chain', chainId] as const, - byChainAndUser: (chainId: number, userAddress?: TAddress) => - [...balanceQueryKeys.byChain(chainId), 'user', toAddress(userAddress)] as const, - byToken: (chainId: number, userAddress: TAddress | undefined, tokenAddress: TAddress) => - [...balanceQueryKeys.byChainAndUser(chainId, userAddress), 'token', toAddress(tokenAddress)] as const, - byTokens: (chainId: number, userAddress: TAddress | undefined, tokenAddresses: TAddress[]) => + byChain: (chainId: number, executionChainId?: number) => + [...balanceQueryKeys.all, 'chain', chainId, 'execution', toExecutionKey(executionChainId)] as const, + byChainAndUser: (chainId: number, executionChainId: number | undefined, userAddress?: TAddress) => + [...balanceQueryKeys.byChain(chainId, executionChainId), 'user', toAddress(userAddress)] as const, + byToken: ( + chainId: number, + executionChainId: number | undefined, + userAddress: TAddress | undefined, + tokenAddress: TAddress + ) => + [ + ...balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), + 'token', + toAddress(tokenAddress) + ] as const, + byTokens: ( + chainId: number, + executionChainId: number | undefined, + userAddress: TAddress | undefined, + tokenAddresses: TAddress[] + ) => [ - ...balanceQueryKeys.byChainAndUser(chainId, userAddress), + ...balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), 'tokens', tokenAddresses.map(toAddress).toSorted().join(',') ] as const diff --git a/src/components/shared/hooks/useBalancesRouting.test.ts b/src/components/shared/hooks/useBalancesRouting.test.ts index 606529886..80ffc34e6 100644 --- a/src/components/shared/hooks/useBalancesRouting.test.ts +++ b/src/components/shared/hooks/useBalancesRouting.test.ts @@ -81,6 +81,21 @@ describe('partitionTokensByBalanceSource', () => { expect(multicallTokens.map(tokenKey)).toEqual([`250:${getAddress(STAKING_B)}`]) }) + it('routes Tenderly-backed canonical chains to multicall when Enso is disabled for them', () => { + const tokens: TUseBalancesTokens[] = [ + { + address: VAULT_B, + chainID: 1, + for: 'vault', + isVaultToken: true + } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [1]) + expect(ensoTokens).toHaveLength(0) + expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`]) + }) + it('dedupes duplicate entries and never routes same token to both sources', () => { const tokens: TUseBalancesTokens[] = [ { address: VAULT_A, chainID: 1, for: 'vault' }, diff --git a/src/components/shared/hooks/useBalancesWithQuery.ts b/src/components/shared/hooks/useBalancesWithQuery.ts index 60222a181..f7a1ac864 100644 --- a/src/components/shared/hooks/useBalancesWithQuery.ts +++ b/src/components/shared/hooks/useBalancesWithQuery.ts @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' +import { resolveExecutionChainId } from '@/config/tenderly' import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TToken } from '../types/mixed' import { isZeroAddress } from '../utils/tools.is' @@ -66,13 +67,14 @@ export function useBalancesWithQuery(props?: TUseBalancesReq): TUseBalancesRes { // Process each chain's tokens for (const [chainIdStr, chainTokens] of Object.entries(tokensByChain)) { const chainId = Number(chainIdStr) + const executionChainId = resolveExecutionChainId(chainId) // Fetch fresh balances for the requested tokens const freshBalances = await fetchTokenBalances(chainId, userAddress, chainTokens, true) // Update ALL possible query keys that might contain these tokens const allQueries = queryClient.getQueriesData>({ - queryKey: balanceQueryKeys.byChainAndUser(chainId, userAddress), + queryKey: balanceQueryKeys.byChainAndUser(chainId, executionChainId, userAddress), exact: false }) diff --git a/src/components/shared/hooks/useChainID.tsx b/src/components/shared/hooks/useChainID.tsx index fbf944a3a..b78e3fa24 100644 --- a/src/components/shared/hooks/useChainID.tsx +++ b/src/components/shared/hooks/useChainID.tsx @@ -7,12 +7,8 @@ export type TUseChainIDRes = { safeChainID: number } -export const toSafeChainID = (chainID: number, fallback: number): number => { - if ([1337, 31337].includes(chainID)) { - return fallback - } - return chainID -} +export const toSafeChainID = (chainID: number, fallback: number): number => + Number.isInteger(chainID) && chainID > 0 ? chainID : fallback export function useChainID(defaultChainID?: number): TUseChainIDRes { const { chainId, switchNetwork } = useChains() diff --git a/src/components/shared/hooks/useChainTimestamp.ts b/src/components/shared/hooks/useChainTimestamp.ts index 9eca195ac..40a89b0a9 100644 --- a/src/components/shared/hooks/useChainTimestamp.ts +++ b/src/components/shared/hooks/useChainTimestamp.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { useBlockNumber, usePublicClient } from 'wagmi' +import { useBlockNumber, usePublicClient } from '@shared/hooks/useAppWagmi' +import { useCallback, useEffect, useState } from 'react' type TChainTimestampState = { blockTimestamp: number @@ -21,30 +21,22 @@ export function useChainTimestamp({ chainId, enabled = true }: { chainId?: numbe const [chainTimestampState, setChainTimestampState] = useState(undefined) const [isLoading, setIsLoading] = useState(false) const [, setTick] = useState(0) - const latestFetchIdRef = useRef(0) const refetch = useCallback(async (): Promise => { if (!enabled || !publicClient) { return } - const fetchId = latestFetchIdRef.current + 1 - latestFetchIdRef.current = fetchId setIsLoading(true) try { const block = blockNumber !== undefined ? await publicClient.getBlock({ blockNumber }) : await publicClient.getBlock() - if (fetchId !== latestFetchIdRef.current) { - return - } setChainTimestampState({ blockTimestamp: Number(block.timestamp), fetchedAtMs: Date.now() }) } finally { - if (fetchId === latestFetchIdRef.current) { - setIsLoading(false) - } + setIsLoading(false) } }, [blockNumber, enabled, publicClient]) diff --git a/src/components/shared/hooks/useChains.tsx b/src/components/shared/hooks/useChains.tsx index 7f83b2ee7..9757bb1c9 100644 --- a/src/components/shared/hooks/useChains.tsx +++ b/src/components/shared/hooks/useChains.tsx @@ -1,22 +1,20 @@ import { useCustomCompareMemo, useDeepCompareMemo } from '@react-hookz/web' import type { TMultiSelectOptionProps } from '@shared/components/MultiSelectDropdown' - +import { SUPPORTED_NETWORKS } from '@shared/utils/constants' import type { Chain } from 'viem' import type { Connector } from 'wagmi' -import { useConfig, useConnect } from 'wagmi' +import { useConnect } from 'wagmi' export function useChainOptions(chains: number[] | null): TMultiSelectOptionProps[] { const { connectors } = useConnect() - const config = useConfig() const injectedChains = useCustomCompareMemo( (): Chain[] | undefined => { connectors //Hard trigger re-render when connectors change - const noFork = config.chains.filter(({ id }): boolean => id !== 1337) - return noFork as Chain[] + return SUPPORTED_NETWORKS as Chain[] }, - [[...connectors], config], - (savedDeps: [Connector[], typeof config], deps: [Connector[], typeof config]): boolean => { + [[...connectors]], + (savedDeps: [Connector[]], deps: [Connector[]]): boolean => { for (const savedDep of savedDeps[0]) { if (!deps[0].find((dep): boolean => dep.id === savedDep.id)) { return false diff --git a/src/components/shared/hooks/useFetchYearnVaults.ts b/src/components/shared/hooks/useFetchYearnVaults.ts index 27feaaaab..225ae09cd 100644 --- a/src/components/shared/hooks/useFetchYearnVaults.ts +++ b/src/components/shared/hooks/useFetchYearnVaults.ts @@ -3,14 +3,14 @@ import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { useDeepCompareMemo } from '@react-hookz/web' import { fetchWithSchema, getFetchQueryKey, useFetch } from '@shared/hooks/useFetch' import type { TDict } from '@shared/types' -import { toAddress } from '@shared/utils' +import { SUPPORTED_NETWORKS, toAddress } from '@shared/utils' import type { TKongVaultList, TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' import { kongVaultListSchema } from '@shared/utils/schemas/kongVaultListSchema' import type { QueryObserverResult } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' -const DEFAULT_CHAIN_IDS = [1, 10, 137, 146, 250, 8453, 42161, 747474] +const DEFAULT_CHAIN_IDS = SUPPORTED_NETWORKS.map((network) => network.id) const VAULT_LIST_ENDPOINT = `${KONG_REST_BASE}/list/vaults` export const isCatalogYearnVault = (item: TKongVaultListItem): boolean => diff --git a/src/components/shared/hooks/useStakingAssetConversions.ts b/src/components/shared/hooks/useStakingAssetConversions.ts index bd673fb8d..f82258772 100644 --- a/src/components/shared/hooks/useStakingAssetConversions.ts +++ b/src/components/shared/hooks/useStakingAssetConversions.ts @@ -8,6 +8,7 @@ import { useMemo } from 'react' import type { Address } from 'viem' import { useConfig } from 'wagmi' import { readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' type TTokenAndChain = { address: TAddress; chainID: number } type TBalanceGetter = (params: TTokenAndChain) => TNormalizedBN @@ -88,14 +89,20 @@ export function useStakingAssetConversions({ abi: readonly unknown[] functionName: string args?: readonly unknown[] - }) => - readContract(config, { - chainId: position.chainID, + }) => { + const executionChainId = resolveExecutionChainId(position.chainID) + if (!executionChainId) { + throw new Error(`No execution chain found for chainId ${position.chainID}`) + } + + return readContract(config, { + chainId: executionChainId, address: request.address, abi: request.abi as any, functionName: request.functionName as any, args: request.args as any }) + } return getStakingWithdrawableAssets({ read, @@ -105,7 +112,7 @@ export function useStakingAssetConversions({ stakingShareBalance: position.stakingShareBalance }) }, - enabled: Boolean(userAddress && position.stakingShareBalance > 0n), + enabled: Boolean(userAddress && position.stakingShareBalance > 0n && resolveExecutionChainId(position.chainID)), staleTime: 60_000, gcTime: 5 * 60_000, retry: 1, diff --git a/src/components/shared/hooks/useSupportedChains.ts b/src/components/shared/hooks/useSupportedChains.ts index 3b76117a8..88925c228 100644 --- a/src/components/shared/hooks/useSupportedChains.ts +++ b/src/components/shared/hooks/useSupportedChains.ts @@ -1,17 +1,13 @@ import { useMemo } from 'react' import type { Chain } from 'viem/chains' -import { retrieveConfig } from '../utils/wagmi' +import { supportedAppChains } from '@/config/supportedChains' /****************************************************************************** - ** The useSupportedChains hook returns an array of supported chains, based on - ** the injected connector. + ** The useSupportedChains hook returns the canonical app chains used by vault + ** filters, URLs, and query-state serialization. *****************************************************************************/ export function useSupportedChains(): Chain[] { - const supportedChains = useMemo((): Chain[] => { - const config = retrieveConfig() - const noFork = config.chains.filter(({ id }): boolean => id !== 1337) - return noFork - }, []) + const chains = useMemo((): Chain[] => [...supportedAppChains], []) - return supportedChains + return chains } diff --git a/src/components/shared/hooks/useTransactionStatusPoller.ts b/src/components/shared/hooks/useTransactionStatusPoller.ts index 3b70085a6..7a67a80be 100644 --- a/src/components/shared/hooks/useTransactionStatusPoller.ts +++ b/src/components/shared/hooks/useTransactionStatusPoller.ts @@ -1,7 +1,6 @@ import { useNotifications } from '@shared/contexts/useNotifications' import type { TNotification } from '@shared/types/notifications' -import { SUPPORTED_NETWORKS } from '@shared/utils' -import { retrieveConfig } from '@shared/utils/wagmi' +import { getNetwork, retrieveConfig } from '@shared/utils/wagmi' import { useCallback, useEffect, useRef } from 'react' import { getBlock, waitForTransactionReceipt } from 'wagmi/actions' @@ -28,16 +27,17 @@ export function useTransactionStatusPoller(notification: TNotification): void { try { const config = retrieveConfig() - const chain = SUPPORTED_NETWORKS.find((network) => network.id === notification.chainId) + const pollingChainId = notification.executionChainId ?? notification.chainId + const chain = getNetwork(pollingChainId) if (!chain) { - console.warn(`Chain ${notification.chainId} not supported for transaction polling`) + console.warn(`Chain ${pollingChainId} not supported for transaction polling`) return } // Wait for transaction receipt with a short timeout to avoid blocking const receipt = await waitForTransactionReceipt(config, { - chainId: notification.chainId, + chainId: pollingChainId, hash: notification.txHash, timeout: 5000 // 5 second timeout to avoid long waits }) @@ -47,7 +47,7 @@ export function useTransactionStatusPoller(notification: TNotification): void { // Get the block information to retrieve the timestamp const block = await getBlock(config, { - chainId: notification.chainId, + chainId: pollingChainId, blockNumber: receipt.blockNumber }) diff --git a/src/components/shared/types/index.ts b/src/components/shared/types/index.ts index a2eae2aad..fb1041b7a 100644 --- a/src/components/shared/types/index.ts +++ b/src/components/shared/types/index.ts @@ -2,3 +2,4 @@ export * from './address' export * from './dropdown' export * from './mixed' export * from './prices' +export * from './tenderly' diff --git a/src/components/shared/types/notifications.ts b/src/components/shared/types/notifications.ts index 93824a85e..fe76c349b 100644 --- a/src/components/shared/types/notifications.ts +++ b/src/components/shared/types/notifications.ts @@ -26,6 +26,7 @@ export type TNotification = { type: TNotificationType address: TAddress chainId: number + executionChainId?: number toChainId?: number // Destination chain ID for cross-chain transactions spenderAddress?: TAddress spenderName?: string @@ -60,6 +61,7 @@ export type TCreateNotificationParams = { fromAddress: TAddress fromSymbol: string fromChainId: number + executionChainId?: number toAddress?: TAddress // optional for approve/claim toSymbol?: string toAmount?: string // expected output amount for withdrawals diff --git a/src/components/shared/types/tenderly.ts b/src/components/shared/types/tenderly.ts new file mode 100644 index 000000000..1fc7e4f5f --- /dev/null +++ b/src/components/shared/types/tenderly.ts @@ -0,0 +1,83 @@ +import type { TAddress } from './address' + +export type TTenderlyConfiguredChainStatus = { + canonicalChainId: number + canonicalChainName: string + executionChainId: number + hasAdminRpc: boolean +} + +export type TTenderlyPanelStatus = { + isTenderlyModeEnabled: boolean + configuredChains: TTenderlyConfiguredChainStatus[] +} + +export type TTenderlySnapshotKind = 'baseline' | 'snapshot' +export type TTenderlySnapshotRecordStatus = 'valid' | 'invalid' + +export type TTenderlySnapshotRecord = { + snapshotId: string + canonicalChainId: number + executionChainId: number + label: string + createdAt: string + kind: TTenderlySnapshotKind + lastKnownStatus: TTenderlySnapshotRecordStatus +} + +export type TTenderlySnapshotRequest = { + canonicalChainId: number + label?: string + isBaseline?: boolean +} + +export type TTenderlyRevertRequest = { + canonicalChainId: number + snapshotId: string +} + +export type TTenderlyRevertResponse = { + success: boolean + revertedSnapshotId: string +} + +export type TTenderlyIncreaseTimeRequest = { + canonicalChainId: number + seconds: number + mineBlock?: boolean +} + +export type TTenderlyIncreaseTimeResponse = { + timeResult: unknown + mineResult?: unknown +} + +export type TTenderlyFundAssetKind = 'native' | 'erc20' +export type TTenderlyFundTokenType = 'asset' | 'vault' | 'staking' + +export type TTenderlyFundableAsset = { + chainId: number + address: TAddress + name: string + symbol: string + decimals: number + assetKind: TTenderlyFundAssetKind + tokenType: TTenderlyFundTokenType + logoURI?: string +} + +export type TTenderlyFundRequest = { + canonicalChainId: number + walletAddress: TAddress + assetKind: TTenderlyFundAssetKind + tokenAddress?: TAddress + symbol: string + decimals: number + amount: string + mode?: 'set' | 'add' +} + +export type TTenderlyFundResponse = { + method: string + result: unknown +} diff --git a/src/components/shared/utils/constants.tsx b/src/components/shared/utils/constants.tsx index b40c2bced..541b0f31b 100755 --- a/src/components/shared/utils/constants.tsx +++ b/src/components/shared/utils/constants.tsx @@ -1,9 +1,11 @@ import type { TAddress, TNDict, TToken } from '@shared/types' import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' +import { supportedAppChains } from '@/config/supportedChains' import { toAddress } from './tools.address' -import { katana } from './wagmi' -export const SUPPORTED_NETWORKS = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] +export const SUPPORTED_NETWORKS = supportedAppChains.length + ? supportedAppChains + : [mainnet, optimism, polygon, fantom, base, arbitrum, sonic] export const MULTICALL3_ADDRESS = toAddress('0xcA11bde05977b3631167028862bE2a173976CA11') diff --git a/src/components/shared/utils/index.ts b/src/components/shared/utils/index.ts index 0147f4c51..98a185b72 100644 --- a/src/components/shared/utils/index.ts +++ b/src/components/shared/utils/index.ts @@ -9,6 +9,7 @@ export * from './format' export * from './handlers' export * from './helpers' export * from './selectorStyles' +export * from './tenderlyPanel' export * from './tools.address' export * from './tools.is' // export * from './tools.math'; diff --git a/src/components/shared/utils/tenderlyPanel.test.ts b/src/components/shared/utils/tenderlyPanel.test.ts new file mode 100644 index 000000000..ed5163552 --- /dev/null +++ b/src/components/shared/utils/tenderlyPanel.test.ts @@ -0,0 +1,333 @@ +import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import type { TDict, TToken } from '@shared/types' +import type { TTenderlyConfiguredChainStatus, TTenderlySnapshotRecord } from '@shared/types/tenderly' +import { describe, expect, it } from 'vitest' +import { + addTenderlyTimeIncrement, + buildTenderlyFundableAssets, + clearTenderlySnapshotBucket, + convertTenderlyTimeAmountToSeconds, + getDefaultTenderlyFundableAssets, + getLastRestorableTenderlySnapshot, + getValidBaselineSnapshot, + markTenderlySnapshotInvalid, + reconcileTenderlySnapshotStorageAfterRevert, + resolveDefaultTenderlyCanonicalChainId, + upsertTenderlySnapshotRecord +} from './tenderlyPanel' + +const configuredChains: TTenderlyConfiguredChainStatus[] = [ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + executionChainId: 694201, + hasAdminRpc: true + }, + { + canonicalChainId: 10, + canonicalChainName: 'Optimism', + executionChainId: 694202, + hasAdminRpc: false + } +] + +const baselineSnapshot: TTenderlySnapshotRecord = { + snapshotId: '0xbaseline', + canonicalChainId: 1, + executionChainId: 694201, + label: 'Baseline', + createdAt: '2026-03-16T00:00:00.000Z', + kind: 'baseline', + lastKnownStatus: 'valid' +} + +describe('resolveDefaultTenderlyCanonicalChainId', () => { + it('prefers the first preferred chain that has admin RPC access', () => { + expect(resolveDefaultTenderlyCanonicalChainId(configuredChains, [10, 1])).toBe(1) + }) + + it('falls back to the first available configured chain', () => { + expect(resolveDefaultTenderlyCanonicalChainId(configuredChains, [8453])).toBe(1) + }) +}) + +describe('snapshot helpers', () => { + it('replaces prior baseline snapshots for the same chain bucket', () => { + const updatedStorage = upsertTenderlySnapshotRecord( + { + '1:694201': [baselineSnapshot] + }, + { + ...baselineSnapshot, + snapshotId: '0xbaseline2', + createdAt: '2026-03-17T00:00:00.000Z' + } + ) + + expect(updatedStorage['1:694201']).toHaveLength(1) + expect(updatedStorage['1:694201']?.[0]?.snapshotId).toBe('0xbaseline2') + }) + + it('marks a snapshot invalid and removes it as the valid baseline', () => { + const updatedStorage = markTenderlySnapshotInvalid( + { + '1:694201': [baselineSnapshot] + }, + { + canonicalChainId: 1, + executionChainId: 694201, + snapshotId: '0xbaseline' + } + ) + + expect(updatedStorage['1:694201']?.[0]?.lastKnownStatus).toBe('invalid') + expect(getValidBaselineSnapshot(updatedStorage['1:694201'] || [])).toBeUndefined() + }) + + it('clears local snapshot history for one chain bucket without touching others', () => { + const snapshotStorage = { + '1:694201': [baselineSnapshot], + '8453:69428453': [ + { + ...baselineSnapshot, + canonicalChainId: 8453, + executionChainId: 69428453, + snapshotId: '0xbase', + label: 'Base baseline' + } + ] + } + + const updatedStorage = clearTenderlySnapshotBucket(snapshotStorage, { + canonicalChainId: 1, + executionChainId: 694201 + }) + + expect(updatedStorage['1:694201']).toBeUndefined() + expect(updatedStorage['8453:69428453']).toEqual(snapshotStorage['8453:69428453']) + }) + + it('returns the latest valid snapshot before falling back to baseline', () => { + const latestSnapshot: TTenderlySnapshotRecord = { + ...baselineSnapshot, + snapshotId: '0xsnapshot-latest', + label: 'Latest snapshot', + kind: 'snapshot', + createdAt: '2026-03-18T00:00:00.000Z' + } + const olderSnapshot: TTenderlySnapshotRecord = { + ...baselineSnapshot, + snapshotId: '0xsnapshot-older', + label: 'Older snapshot', + kind: 'snapshot', + createdAt: '2026-03-17T00:00:00.000Z' + } + + expect(getLastRestorableTenderlySnapshot([baselineSnapshot, olderSnapshot, latestSnapshot])?.snapshotId).toBe( + '0xsnapshot-latest' + ) + expect( + getLastRestorableTenderlySnapshot([{ ...latestSnapshot, lastKnownStatus: 'invalid' }, baselineSnapshot]) + ?.snapshotId + ).toBe('0xbaseline') + }) + + it('invalidates spent snapshots and stores a replacement snapshot after revert', () => { + const revertedSnapshot: TTenderlySnapshotRecord = { + ...baselineSnapshot, + snapshotId: '0xsnapshot-current', + label: 'Current snapshot', + kind: 'snapshot', + createdAt: '2026-03-18T00:00:00.000Z' + } + const descendantSnapshot: TTenderlySnapshotRecord = { + ...baselineSnapshot, + snapshotId: '0xsnapshot-descendant', + label: 'Descendant snapshot', + kind: 'snapshot', + createdAt: '2026-03-19T00:00:00.000Z' + } + const replacementSnapshot: TTenderlySnapshotRecord = { + ...revertedSnapshot, + snapshotId: '0xsnapshot-replacement', + label: 'Replacement snapshot', + createdAt: '2026-03-20T00:00:00.000Z' + } + + const updatedStorage = reconcileTenderlySnapshotStorageAfterRevert( + { + '1:694201': [baselineSnapshot, revertedSnapshot, descendantSnapshot] + }, + { + revertedSnapshotRecord: revertedSnapshot, + replacementSnapshotRecord: replacementSnapshot + } + ) + + expect( + updatedStorage['1:694201']?.find((record) => record.snapshotId === revertedSnapshot.snapshotId)?.lastKnownStatus + ).toBe('invalid') + expect( + updatedStorage['1:694201']?.find((record) => record.snapshotId === descendantSnapshot.snapshotId)?.lastKnownStatus + ).toBe('invalid') + expect( + updatedStorage['1:694201']?.find((record) => record.snapshotId === replacementSnapshot.snapshotId) + ).toMatchObject({ + snapshotId: '0xsnapshot-replacement', + lastKnownStatus: 'valid', + kind: 'snapshot' + }) + expect(getLastRestorableTenderlySnapshot(updatedStorage['1:694201'] || [])?.snapshotId).toBe( + '0xsnapshot-replacement' + ) + }) +}) + +describe('convertTenderlyTimeAmountToSeconds', () => { + it('converts curated units into seconds', () => { + expect(convertTenderlyTimeAmountToSeconds(5, 'minutes')).toBe(300) + expect(convertTenderlyTimeAmountToSeconds(2, 'hours')).toBe(7_200) + expect(convertTenderlyTimeAmountToSeconds(14, 'days')).toBe(1_209_600) + }) +}) + +describe('addTenderlyTimeIncrement', () => { + it('adds preset time to the current input without executing immediately', () => { + expect( + addTenderlyTimeIncrement({ + currentAmount: 14, + currentUnit: 'days', + addedAmount: 1, + addedUnit: 'hours' + }) + ).toEqual({ + amount: 14.041667, + unit: 'days', + seconds: 1_213_200 + }) + }) + + it('uses the preset unit when the current input is empty', () => { + expect( + addTenderlyTimeIncrement({ + currentAmount: 0, + currentUnit: 'days', + addedAmount: 1, + addedUnit: 'days' + }) + ).toEqual({ + amount: 1, + unit: 'days', + seconds: 86_400 + }) + }) +}) + +describe('buildTenderlyFundableAssets', () => { + it('includes native, token-list, vault, and staking assets for the selected chain', () => { + const tokenLists = { + 1: { + '0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9': { + address: '0x7Fc66500c84A76Ad7E9C93437bFc5Ac33E2DDAE9', + name: 'Aave Token', + symbol: 'AAVE', + decimals: 18, + chainID: 1, + value: 0, + balance: { raw: 0n, normalized: 0, display: '0', decimals: 18 } + } satisfies TToken, + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + chainID: 1, + value: 0, + balance: { raw: 0n, normalized: 0, display: '0', decimals: 6 } + } satisfies TToken + } + } + const allVaults = { + '0xvault': { + address: '0x1111111111111111111111111111111111111111', + chainId: 1, + name: 'Test Vault', + symbol: 'yvTEST', + decimals: 18, + token: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6 + }, + staking: { + address: '0x2222222222222222222222222222222222222222', + available: true, + source: '', + rewards: [] + } + } + } as unknown as TDict + + const assets = buildTenderlyFundableAssets({ + chainId: 1, + tokenLists, + allVaults + }) + + expect(assets.some((asset) => asset.assetKind === 'native' && asset.symbol === 'ETH')).toBe(true) + expect(assets.some((asset) => asset.symbol === 'USDC' && asset.tokenType === 'asset')).toBe(true) + expect(assets.some((asset) => asset.symbol === 'yvTEST' && asset.tokenType === 'vault')).toBe(true) + expect(assets.some((asset) => asset.symbol === 'yvTEST' && asset.tokenType === 'staking')).toBe(true) + expect(assets.findIndex((asset) => asset.symbol === 'USDC')).toBeLessThan( + assets.findIndex((asset) => asset.symbol === 'AAVE') + ) + }) + + it('builds a deduplicated default asset list for the faucet without repeated common symbols', () => { + const defaultAssets = getDefaultTenderlyFundableAssets( + [ + { + chainId: 1, + address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + assetKind: 'native', + tokenType: 'asset' + }, + { + chainId: 1, + address: '0x13Cc8D626445c6fcCC548aAE172CBACF572EF5A4', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + assetKind: 'erc20', + tokenType: 'asset' + }, + { + chainId: 1, + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + assetKind: 'erc20', + tokenType: 'asset' + }, + { + chainId: 1, + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + name: 'Dai Stablecoin', + symbol: 'DAI', + decimals: 18, + assetKind: 'erc20', + tokenType: 'asset' + } + ], + 14 + ) + + expect(defaultAssets.map((asset) => asset.symbol)).toEqual(['ETH', 'USDT', 'DAI']) + expect(defaultAssets[1]?.address).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7') + }) +}) diff --git a/src/components/shared/utils/tenderlyPanel.ts b/src/components/shared/utils/tenderlyPanel.ts new file mode 100644 index 000000000..d4d7063bf --- /dev/null +++ b/src/components/shared/utils/tenderlyPanel.ts @@ -0,0 +1,449 @@ +import { + getVaultAddress, + getVaultChainID, + getVaultDecimals, + getVaultName, + getVaultStaking, + getVaultSymbol, + getVaultToken, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import { canonicalChains } from '@/config/chainDefinitions' +import type { TDict, TNDict, TToken } from '../types' +import type { + TTenderlyConfiguredChainStatus, + TTenderlyFundableAsset, + TTenderlyFundTokenType, + TTenderlySnapshotRecord +} from '../types/tenderly' +import { ETH_TOKEN_ADDRESS } from './constants' +import { toAddress } from './tools.address' +import { isZeroAddress } from './tools.is' + +export type TTenderlySnapshotStorage = Record +export type TTenderlyFastForwardUnit = 'minutes' | 'hours' | 'days' + +type TTenderlyTimeInput = { + amount: number + unit: TTenderlyFastForwardUnit + seconds: number +} + +const SNAPSHOT_KIND_ORDER: Record = { + baseline: 0, + snapshot: 1 +} + +const TOKEN_TYPE_PRIORITY: Record = { + asset: 0, + vault: 1, + staking: 2 +} + +const TIME_UNIT_SECONDS: Record = { + minutes: 60, + hours: 3_600, + days: 86_400 +} + +const COMMON_TENDERLY_ASSET_SYMBOLS = [ + 'ETH', + 'WETH', + 'WBTC', + 'USDC', + 'USDT', + 'DAI', + 'USDS', + 'USDE', + 'SUSDE', + 'CRVUSD', + 'SDAI', + 'YVUSD' +] + +const COMMON_TENDERLY_ASSET_PRIORITY = COMMON_TENDERLY_ASSET_SYMBOLS.reduce>( + (accumulator, symbol, index) => { + accumulator[symbol] = index + return accumulator + }, + {} +) + +const COMMON_TENDERLY_PREFERRED_ADDRESSES: Record>> = { + 1: { + WETH: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + WBTC: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + DAI: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + USDS: '0xdC035D45d973E3EC169d2276DDab16f1e407384F', + USDE: '0x4c9EDD5852cd905f086C759E8383e09bff1E68B3', + SUSDE: '0x9D39A5DE30e57443BfF2A8307A4256c8797A3497', + CRVUSD: '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E', + SDAI: '0x83F20F44975D03b1b09e64809B757c47f942BEeA' + } +} + +export const TENDERLY_SNAPSHOT_STORAGE_KEY = 'yearn.fi/tenderly/snapshots' + +export function getTenderlySnapshotBucketKey(canonicalChainId: number, executionChainId: number): string { + return `${canonicalChainId}:${executionChainId}` +} + +export function sortTenderlySnapshotRecords(records: TTenderlySnapshotRecord[]): TTenderlySnapshotRecord[] { + return records.toSorted((left, right) => { + const kindOrder = SNAPSHOT_KIND_ORDER[left.kind] - SNAPSHOT_KIND_ORDER[right.kind] + if (kindOrder !== 0) { + return kindOrder + } + + return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime() + }) +} + +export function getValidBaselineSnapshot(records: TTenderlySnapshotRecord[]): TTenderlySnapshotRecord | undefined { + return sortTenderlySnapshotRecords(records).find( + (record) => record.kind === 'baseline' && record.lastKnownStatus === 'valid' + ) +} + +export function getLastRestorableTenderlySnapshot( + records: TTenderlySnapshotRecord[] +): TTenderlySnapshotRecord | undefined { + const sortedRecords = sortTenderlySnapshotRecords(records) + const latestSnapshot = sortedRecords.find( + (record) => record.kind === 'snapshot' && record.lastKnownStatus === 'valid' + ) + + return latestSnapshot || getValidBaselineSnapshot(sortedRecords) +} + +export function upsertTenderlySnapshotRecord( + snapshotStorage: TTenderlySnapshotStorage, + record: TTenderlySnapshotRecord +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(record.canonicalChainId, record.executionChainId) + const previousRecords = snapshotStorage[bucketKey] || [] + const filteredRecords = previousRecords.filter((existingRecord) => { + if (existingRecord.snapshotId === record.snapshotId) { + return false + } + + if (record.kind === 'baseline' && existingRecord.kind === 'baseline') { + return false + } + + return true + }) + + return { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords([record, ...filteredRecords]) + } +} + +export function markTenderlySnapshotInvalid( + snapshotStorage: TTenderlySnapshotStorage, + params: { canonicalChainId: number; executionChainId: number; snapshotId: string } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(params.canonicalChainId, params.executionChainId) + const updatedRecords = (snapshotStorage[bucketKey] || []).map((record) => + record.snapshotId === params.snapshotId ? { ...record, lastKnownStatus: 'invalid' as const } : record + ) + + return { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords(updatedRecords) + } +} + +export function reconcileTenderlySnapshotStorageAfterRevert( + snapshotStorage: TTenderlySnapshotStorage, + params: { + revertedSnapshotRecord: TTenderlySnapshotRecord + replacementSnapshotRecord?: TTenderlySnapshotRecord + } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey( + params.revertedSnapshotRecord.canonicalChainId, + params.revertedSnapshotRecord.executionChainId + ) + const previousRecords = snapshotStorage[bucketKey] || [] + + if (previousRecords.length === 0) { + return params.replacementSnapshotRecord + ? upsertTenderlySnapshotRecord(snapshotStorage, params.replacementSnapshotRecord) + : snapshotStorage + } + + const revertedAt = Date.parse(params.revertedSnapshotRecord.createdAt) + const updatedRecords = previousRecords.map((record) => { + const recordCreatedAt = Date.parse(record.createdAt) + const isLaterSnapshot = + record.kind === 'snapshot' && + Number.isFinite(revertedAt) && + Number.isFinite(recordCreatedAt) && + recordCreatedAt > revertedAt + + if (record.snapshotId !== params.revertedSnapshotRecord.snapshotId && !isLaterSnapshot) { + return record + } + + return { ...record, lastKnownStatus: 'invalid' as const } + }) + const nextStorage = { + ...snapshotStorage, + [bucketKey]: sortTenderlySnapshotRecords(updatedRecords) + } + + return params.replacementSnapshotRecord + ? upsertTenderlySnapshotRecord(nextStorage, params.replacementSnapshotRecord) + : nextStorage +} + +export function clearTenderlySnapshotBucket( + snapshotStorage: TTenderlySnapshotStorage, + params: { canonicalChainId: number; executionChainId: number } +): TTenderlySnapshotStorage { + const bucketKey = getTenderlySnapshotBucketKey(params.canonicalChainId, params.executionChainId) + + if (!(bucketKey in snapshotStorage)) { + return snapshotStorage + } + + const nextStorage = { ...snapshotStorage } + delete nextStorage[bucketKey] + return nextStorage +} + +export function resolveDefaultTenderlyCanonicalChainId( + configuredChains: TTenderlyConfiguredChainStatus[], + preferredChainIds: Array +): number | undefined { + const availableCanonicalChainIds = configuredChains + .filter((chain) => chain.hasAdminRpc) + .map((chain) => chain.canonicalChainId) + const preferredChainId = preferredChainIds.find((chainId) => availableCanonicalChainIds.includes(Number(chainId))) + + return preferredChainId ?? availableCanonicalChainIds[0] +} + +export function convertTenderlyTimeAmountToSeconds(amount: number, unit: TTenderlyFastForwardUnit): number { + const normalizedAmount = Number.isFinite(amount) ? Math.max(0, amount) : 0 + + return Math.round(normalizedAmount * TIME_UNIT_SECONDS[unit]) +} + +export function addTenderlyTimeIncrement(params: { + currentAmount: number + currentUnit: TTenderlyFastForwardUnit + addedAmount: number + addedUnit: TTenderlyFastForwardUnit +}): TTenderlyTimeInput { + const currentSeconds = convertTenderlyTimeAmountToSeconds(params.currentAmount, params.currentUnit) + const addedSeconds = convertTenderlyTimeAmountToSeconds(params.addedAmount, params.addedUnit) + + if (currentSeconds <= 0) { + return { + amount: params.addedAmount, + unit: params.addedUnit, + seconds: addedSeconds + } + } + + const totalSeconds = currentSeconds + addedSeconds + + return { + amount: Number((totalSeconds / TIME_UNIT_SECONDS[params.currentUnit]).toFixed(6)), + unit: params.currentUnit, + seconds: totalSeconds + } +} + +function getCanonicalNativeAsset(chainId: number): TTenderlyFundableAsset | undefined { + const chain = canonicalChains.find((item) => item.id === chainId) + if (!chain) { + return undefined + } + + return { + chainId, + address: toAddress(ETH_TOKEN_ADDRESS), + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + assetKind: 'native', + tokenType: 'asset' + } +} + +function mergeFundableAsset( + accumulator: Record, + asset: TTenderlyFundableAsset +): Record { + const key = `${asset.chainId}:${toAddress(asset.address)}` + const existingAsset = accumulator[key] + + if (!existingAsset) { + accumulator[key] = asset + return accumulator + } + + accumulator[key] = + TOKEN_TYPE_PRIORITY[asset.tokenType] > TOKEN_TYPE_PRIORITY[existingAsset.tokenType] + ? { ...asset, logoURI: asset.logoURI || existingAsset.logoURI } + : { + ...existingAsset, + logoURI: existingAsset.logoURI || asset.logoURI + } + + return accumulator +} + +function getTenderlyFundableAssetPriority(asset: TTenderlyFundableAsset): number { + return COMMON_TENDERLY_ASSET_PRIORITY[String(asset.symbol || '').toUpperCase()] ?? Number.MAX_SAFE_INTEGER +} + +function getTenderlyPreferredAddressPriority(asset: TTenderlyFundableAsset): number { + const preferredAddress = + COMMON_TENDERLY_PREFERRED_ADDRESSES[asset.chainId]?.[String(asset.symbol || '').toUpperCase()] + + if (!preferredAddress) { + return 1 + } + + return toAddress(asset.address) === toAddress(preferredAddress) ? 0 : 1 +} + +function compareTenderlyFundableAssets(left: TTenderlyFundableAsset, right: TTenderlyFundableAsset): number { + if (left.assetKind !== right.assetKind) { + return left.assetKind === 'native' ? -1 : 1 + } + + const commonAssetPriority = getTenderlyFundableAssetPriority(left) - getTenderlyFundableAssetPriority(right) + if (commonAssetPriority !== 0) { + return commonAssetPriority + } + + const preferredAddressPriority = + getTenderlyPreferredAddressPriority(left) - getTenderlyPreferredAddressPriority(right) + if (preferredAddressPriority !== 0) { + return preferredAddressPriority + } + + if (left.tokenType !== right.tokenType) { + return TOKEN_TYPE_PRIORITY[left.tokenType] - TOKEN_TYPE_PRIORITY[right.tokenType] + } + + const symbolOrder = left.symbol.localeCompare(right.symbol, 'en-US') + if (symbolOrder !== 0) { + return symbolOrder + } + + const nameOrder = left.name.localeCompare(right.name, 'en-US') + if (nameOrder !== 0) { + return nameOrder + } + + return left.address.localeCompare(right.address, 'en-US') +} + +function toFundableAssetFromToken(token: TToken, tokenType: TTenderlyFundTokenType = 'asset'): TTenderlyFundableAsset { + return { + chainId: token.chainID, + address: toAddress(token.address), + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + assetKind: toAddress(token.address) === toAddress(ETH_TOKEN_ADDRESS) ? 'native' : 'erc20', + tokenType, + logoURI: token.logoURI + } +} + +export function buildTenderlyFundableAssets(params: { + chainId: number + tokenLists: TNDict> + allVaults: TDict +}): TTenderlyFundableAsset[] { + const nativeAsset = getCanonicalNativeAsset(params.chainId) + const tokenListAssets = Object.values(params.tokenLists[params.chainId] || {}).map((token) => + toFundableAssetFromToken(token) + ) + const vaultAssets = Object.values(params.allVaults) + .filter((vault) => getVaultChainID(vault) === params.chainId) + .reduce((accumulator, vault) => { + const assetToken = getVaultToken(vault) + const vaultAddress = toAddress(getVaultAddress(vault)) + const staking = getVaultStaking(vault) + + const nextAssets = [ + !isZeroAddress(toAddress(assetToken.address)) + ? { + chainId: params.chainId, + address: toAddress(assetToken.address), + name: assetToken.name, + symbol: assetToken.symbol, + decimals: assetToken.decimals, + assetKind: toAddress(assetToken.address) === toAddress(ETH_TOKEN_ADDRESS) ? 'native' : 'erc20', + tokenType: 'asset' as const + } + : undefined, + !isZeroAddress(vaultAddress) + ? { + chainId: params.chainId, + address: vaultAddress, + name: getVaultName(vault), + symbol: getVaultSymbol(vault), + decimals: getVaultDecimals(vault), + assetKind: 'erc20' as const, + tokenType: 'vault' as const + } + : undefined, + !isZeroAddress(toAddress(staking.address)) + ? { + chainId: params.chainId, + address: toAddress(staking.address), + name: `${getVaultName(vault)} Staking`, + symbol: getVaultSymbol(vault), + decimals: getVaultDecimals(vault), + assetKind: 'erc20' as const, + tokenType: 'staking' as const + } + : undefined + ].filter(Boolean) + + return accumulator.concat(nextAssets as TTenderlyFundableAsset[]) + }, []) + + return Object.values( + [nativeAsset, ...tokenListAssets, ...vaultAssets] + .filter(Boolean) + .reduce>( + (accumulator, asset) => mergeFundableAsset(accumulator, asset as TTenderlyFundableAsset), + {} + ) + ).toSorted(compareTenderlyFundableAssets) +} + +export function getDefaultTenderlyFundableAssets( + assets: TTenderlyFundableAsset[], + limit = 14 +): TTenderlyFundableAsset[] { + const seenKeys = new Set() + const dedupedAssets = assets.toSorted(compareTenderlyFundableAssets).filter((asset) => { + const dedupeKey = + asset.assetKind === 'native' + ? `native:${asset.chainId}` + : `${asset.tokenType}:${String(asset.symbol || '').toUpperCase()}` + + if (seenKeys.has(dedupeKey)) { + return false + } + + seenKeys.add(dedupeKey) + return true + }) + + return dedupedAssets.slice(0, limit) +} diff --git a/src/components/shared/utils/wagmi/actions.ts b/src/components/shared/utils/wagmi/actions.ts index 3d5ff4f04..ab9a9890d 100755 --- a/src/components/shared/utils/wagmi/actions.ts +++ b/src/components/shared/utils/wagmi/actions.ts @@ -15,20 +15,14 @@ import { handleTx, retrieveConfig, toWagmiProvider } from '@shared/utils/wagmi' import { erc20Abi } from 'viem' import type { Connector } from 'wagmi' import { readContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' -interface WindowWithCustomEthereum extends Window { - ethereum?: { - useForknetForMainnet?: boolean +function getExecutionChainID(chainID: number): number { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined) { + throw new Error(`Chain ${chainID} is not enabled for execution`) } -} - -function getChainID(chainID: number): number { - if (typeof window !== 'undefined' && (window as WindowWithCustomEthereum)?.ethereum?.useForknetForMainnet) { - if (chainID === 1) { - return 1337 - } - } - return chainID + return executionChainId } //Because USDT do not return a boolean on approve, we need to use this ABI @@ -64,7 +58,7 @@ export async function isApprovedERC20( const result = await readContract(retrieveConfig(), { ...wagmiProvider, abi: erc20Abi, - chainId: getChainID(chainID), + chainId: getExecutionChainID(chainID), address: tokenAddress, functionName: 'allowance', args: [wagmiProvider.address, spender] @@ -86,7 +80,7 @@ export async function allowanceOf(props: TAllowanceOf): Promise { const wagmiProvider = await toWagmiProvider(props.connector) const result = await readContract(retrieveConfig(), { ...wagmiProvider, - chainId: getChainID(props.chainID), + chainId: getExecutionChainID(props.chainID), abi: erc20Abi, address: props.tokenAddress, functionName: 'allowance', @@ -198,14 +192,6 @@ export async function depositETH(props: TDepositEth): Promise { value: props.amount }) } - case 1337: { - return await handleTx(props, { - address: getEthZapperContract(1), - abi: ZAP_ETH_TO_YVETH_ABI, - functionName: 'deposit', - value: props.amount - }) - } default: { throw new Error('Invalid chainId') } @@ -277,14 +263,6 @@ export async function withdrawETH(props: TWithdrawEth): Promise { args: [props.amount] }) } - case 1337: { - return await handleTx(props, { - address: getEthZapperContract(1), - abi: ZAP_ETH_TO_YVETH_ABI, - functionName: 'withdraw', - args: [props.amount] - }) - } default: { throw new Error('Invalid chainId') } diff --git a/src/components/shared/utils/wagmi/networks.ts b/src/components/shared/utils/wagmi/networks.ts index 369ad2386..b07b25082 100644 --- a/src/components/shared/utils/wagmi/networks.ts +++ b/src/components/shared/utils/wagmi/networks.ts @@ -26,30 +26,3 @@ export const localhost = { } } } as const satisfies Chain - -export const anotherLocalhost = { - id: 5402, - name: 'Localhost', - nativeCurrency: { - decimals: 18, - name: 'Ether', - symbol: 'ETH' - }, - rpcUrls: { - default: { http: ['http://localhost:8555', 'http://0.0.0.0:8555'] }, - public: { http: ['http://localhost:8555', 'http://0.0.0.0:8555'] } - }, - contracts: { - ensRegistry: { - address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' - }, - ensUniversalResolver: { - address: '0xE4Acdd618deED4e6d2f03b9bf62dc6118FC9A4da', - blockCreated: 16773775 - }, - multicall3: { - address: '0xca11bde05977b3631167028862be2a173976ca11', - blockCreated: 14353601 - } - } -} as const satisfies Chain diff --git a/src/components/shared/utils/wagmi/provider.ts b/src/components/shared/utils/wagmi/provider.ts index c6786a831..1c89c77fc 100644 --- a/src/components/shared/utils/wagmi/provider.ts +++ b/src/components/shared/utils/wagmi/provider.ts @@ -10,6 +10,7 @@ import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { resolveExecutionChainId } from '@/config/tenderly' import type { TAddress } from '../../types/address' import { assert, assertAddress } from '../assert' import { toBigInt } from '../format' @@ -18,12 +19,6 @@ import { retrieveConfig } from './config' import type { TTxResponse } from './transaction' import { defaultTxStatus } from './transaction' -interface WindowWithCustomEthereum extends Window { - ethereum?: { - useForknetForMainnet?: boolean - } -} - export type TWagmiProviderContract = { walletClient: Client chainId: number @@ -73,21 +68,21 @@ export async function handleTx(args: TWriteTransaction, props: TPrepareWriteCont const config = retrieveConfig() args.statusHandler?.({ ...defaultTxStatus, pending: true }) - let wagmiProvider = await toWagmiProvider(args.connector) - - // Use debug mode - if ((window as WindowWithCustomEthereum).ethereum?.useForknetForMainnet) { - if (args.chainID === 1) { - args.chainID = 1337 + const targetChainId = resolveExecutionChainId(args.chainID) + if (targetChainId === undefined) { + return { + isSuccessful: false, + error: new Error(`Chain ${args.chainID} is not enabled for execution`) } } + let wagmiProvider = await toWagmiProvider(args.connector) /******************************************************************************************* ** First, make sure we are using the correct chainID. ******************************************************************************************/ - if (wagmiProvider.chainId !== args.chainID) { + if (wagmiProvider.chainId !== targetChainId) { try { - await switchChain(config, { chainId: args.chainID }) + await switchChain(config, { chainId: targetChainId }) } catch (error) { if (!(error instanceof BaseError)) { return { isSuccessful: false, error } @@ -105,18 +100,19 @@ export async function handleTx(args: TWriteTransaction, props: TPrepareWriteCont wagmiProvider = await toWagmiProvider(args.connector) assertAddress(props.address, 'contractAddress') assertAddress(wagmiProvider.address, 'userAddress') - assert(wagmiProvider.chainId === args.chainID, 'ChainID mismatch') + assert(wagmiProvider.chainId === targetChainId, 'ChainID mismatch') try { const simulateContractConfig = await simulateContract(config, { ...wagmiProvider, ...(props as SimulateContractParameters), + chainId: targetChainId, address: props.address, value: toBigInt(props.value) }) const hash = await writeContract(config, simulateContractConfig.request) args.txHashHandler?.(hash) const receipt = await waitForTransactionReceipt(config, { - chainId: wagmiProvider.chainId, + chainId: targetChainId, hash, confirmations: props.confirmation || 2 }) diff --git a/src/components/shared/utils/wagmi/utils.test.ts b/src/components/shared/utils/wagmi/utils.test.ts new file mode 100644 index 000000000..9f94e7a08 --- /dev/null +++ b/src/components/shared/utils/wagmi/utils.test.ts @@ -0,0 +1,23 @@ +import { mainnet } from 'viem/chains' +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('getNetwork', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('@/config/tenderly') + }) + + it('leaves the default block explorer empty for Tenderly execution chains without explicit explorer URIs', async () => { + vi.doMock('@/config/tenderly', () => ({ + resolveExecutionChainId: (chainId?: number) => chainId, + resolveTenderlyExplorerUriForExecutionChainId: () => undefined, + resolveTenderlyRpcUriForExecutionChainId: (chainId?: number) => + chainId === 73571 ? 'https://rpc.tenderly.ethereum.example' : undefined, + supportedChainLookup: [{ ...mainnet, id: 73571, name: 'Ethereum Tenderly', blockExplorers: undefined }] + })) + + const { getNetwork } = await import('./utils') + + expect(getNetwork(73571).defaultBlockExplorer).toBe('') + }) +}) diff --git a/src/components/shared/utils/wagmi/utils.ts b/src/components/shared/utils/wagmi/utils.ts index 24fdfded1..3c92f2d16 100644 --- a/src/components/shared/utils/wagmi/utils.ts +++ b/src/components/shared/utils/wagmi/utils.ts @@ -1,10 +1,17 @@ import type { Chain, PublicClient } from 'viem' import { createPublicClient, defineChain, http } from 'viem' import * as wagmiChains from 'viem/chains' +import { katana } from '@/config/chainDefinitions' +import { + resolveExecutionChainId, + resolveTenderlyExplorerUriForExecutionChainId, + resolveTenderlyRpcUriForExecutionChainId, + supportedChainLookup +} from '@/config/tenderly' import type { TAddress } from '../../types/address' import type { TDict, TNDict } from '../../types/mixed' import { retrieveConfig } from './config' -import { anotherLocalhost, localhost } from './networks' +import { localhost } from './networks' export type TChainContract = { address: TAddress @@ -39,32 +46,6 @@ const rari = defineChain({ } }) -export const katana = defineChain({ - id: 747474, - name: 'Katana', - nativeCurrency: { - decimals: 18, - name: 'Ether', - symbol: 'ETH' - }, - rpcUrls: { - default: { http: ['https://rpc.katanarpc.com'] } - }, - blockExplorers: { - default: { - name: 'Katana Explorer', - url: 'https://katanascan.com' - } - }, - contracts: { - multicall3: { - address: '0xcA11bde05977b3631167028862bE2a173976CA11', - blockCreated: 1898013 - } - }, - testnet: false -}) - /*************************************************************************************************** ** Extended Chain type is used to add additional properties to the basic wagmi Chain type. ** Ee need to add: @@ -88,6 +69,14 @@ const isChain = (chain: wagmiChains.Chain | unknown): chain is wagmiChains.Chain } export function getRpcUriFor(chainId: number | string): string { + const normalizedChainId = Number(chainId) + if (Number.isInteger(normalizedChainId)) { + const tenderlyRpc = resolveTenderlyRpcUriForExecutionChainId(normalizedChainId) + if (tenderlyRpc) { + return tenderlyRpc + } + } + const key = `VITE_RPC_URI_FOR_${chainId}` const value = import.meta.env[key] if (typeof value !== 'string') { @@ -138,46 +127,73 @@ function getInfuraBaseURL(chainID: number): string { return '' } -function initIndexedWagmiChains(): TNDict { - const _indexedWagmiChains: TNDict = {} - for (const chain of Object.values({ ...wagmiChains, rari, katana })) { - if (isChain(chain)) { - const baseChain = chain as unknown as TExtendedChain - const extendedChain = - baseChain.id === 1337 - ? (localhost as unknown as TExtendedChain) - : baseChain.id === 5402 - ? (anotherLocalhost as unknown as TExtendedChain) - : baseChain - - extendedChain.contracts = { - ...extendedChain.contracts - } +function toExtendedChain(chain: Chain): TExtendedChain { + const baseChain = (chain.id === localhost.id ? localhost : chain) as Chain - const newRPC = getRpcUriFor(extendedChain.id) - const oldRPC = - import.meta.env.VITE_JSON_RPC_URI?.[extendedChain.id] || import.meta.env.VITE_JSON_RPC_URL?.[extendedChain.id] - if (!newRPC && oldRPC) { - console.debug( - `VITE_JSON_RPC_URI[${extendedChain.id}] is deprecated. Please use VITE_RPC_URI_FOR_${extendedChain.id}` - ) - } - const defaultJsonRPCURL = extendedChain?.rpcUrls?.public?.http?.[0] - - extendedChain.defaultRPC = newRPC || oldRPC || defaultJsonRPCURL || '' - extendedChain.rpcUrls.alchemy = { http: [getAlchemyBaseURL(extendedChain.id)] } - extendedChain.rpcUrls.infura = { http: [getInfuraBaseURL(extendedChain.id)] } - - const http = [extendedChain.defaultRPC, ...extendedChain.rpcUrls.default.http].filter(Boolean) - extendedChain.rpcUrls.default.http = http - extendedChain.defaultBlockExplorer = - extendedChain.blockExplorers?.etherscan?.url || - extendedChain.blockExplorers?.default.url || - 'https://etherscan.io' - _indexedWagmiChains[extendedChain.id] = extendedChain + const extendedChain = { + ...(baseChain as TExtendedChain), + rpcUrls: { + ...baseChain.rpcUrls, + default: { + ...baseChain.rpcUrls.default, + http: [...(baseChain.rpcUrls.default?.http || [])] + }, + public: baseChain.rpcUrls.public + ? { + ...baseChain.rpcUrls.public, + http: [...(baseChain.rpcUrls.public.http || [])] + } + : undefined + }, + blockExplorers: baseChain.blockExplorers + ? { + ...baseChain.blockExplorers, + default: { + ...baseChain.blockExplorers.default + } + } + : undefined, + contracts: { + ...((baseChain as TExtendedChain).contracts || {}) } + } as TExtendedChain + + const newRPC = getRpcUriFor(extendedChain.id) + const oldRPC = + import.meta.env.VITE_JSON_RPC_URI?.[extendedChain.id] || import.meta.env.VITE_JSON_RPC_URL?.[extendedChain.id] + if (!newRPC && oldRPC) { + console.debug( + `VITE_JSON_RPC_URI[${extendedChain.id}] is deprecated. Please use VITE_RPC_URI_FOR_${extendedChain.id}` + ) } - return _indexedWagmiChains + + const defaultJsonRPCURL = extendedChain.rpcUrls.public?.http?.[0] || extendedChain.rpcUrls.default?.http?.[0] + extendedChain.defaultRPC = newRPC || oldRPC || defaultJsonRPCURL || '' + extendedChain.rpcUrls.alchemy = { http: [getAlchemyBaseURL(extendedChain.id)] } + extendedChain.rpcUrls.infura = { http: [getInfuraBaseURL(extendedChain.id)] } + extendedChain.rpcUrls.default.http = [ + ...new Set([extendedChain.defaultRPC, ...extendedChain.rpcUrls.default.http].filter(Boolean)) + ] + const tenderlyExecutionExplorer = resolveTenderlyExplorerUriForExecutionChainId(extendedChain.id) + const isTenderlyExecutionChain = Boolean(resolveTenderlyRpcUriForExecutionChainId(extendedChain.id)) + extendedChain.defaultBlockExplorer = + tenderlyExecutionExplorer || + extendedChain.blockExplorers?.etherscan?.url || + extendedChain.blockExplorers?.default.url || + (isTenderlyExecutionChain ? '' : 'https://etherscan.io') + + return extendedChain +} + +function initIndexedWagmiChains(): TNDict { + const indexedChains: TNDict = {} + const baseChains = Object.values({ ...wagmiChains, rari, katana, localhost }).filter(isChain) + + for (const chain of [...baseChains, ...supportedChainLookup]) { + indexedChains[chain.id] = toExtendedChain(chain) + } + + return indexedChains } export const indexedWagmiChains: TNDict = initIndexedWagmiChains() @@ -208,21 +224,24 @@ export function getNetwork(chainID: number): TExtendedChain { } export function getClient(chainID: number): PublicClient { - if (!indexedWagmiChains[chainID]) { + const executionChainId = resolveExecutionChainId(chainID) + if (executionChainId === undefined || !indexedWagmiChains[executionChainId]) { throw new Error(`Chain ${chainID} is not supported`) } - const chainConfig = indexedWagmiChains?.[chainID] || retrieveConfig().chains.find((chain) => chain.id === chainID) + const chainConfig = + indexedWagmiChains[executionChainId] || retrieveConfig().chains.find((chain) => chain.id === executionChainId) - const newRPC = getRpcUriFor(chainID) - const oldRPC = import.meta.env.VITE_JSON_RPC_URI?.[chainID] || import.meta.env.VITE_JSON_RPC_URL?.[chainID] + const newRPC = getRpcUriFor(executionChainId) + const oldRPC = + import.meta.env.VITE_JSON_RPC_URI?.[executionChainId] || import.meta.env.VITE_JSON_RPC_URL?.[executionChainId] const url = newRPC || oldRPC || - chainConfig.rpcUrls.default.http[0] || - chainConfig.rpcUrls.alchemy.http[0] || - chainConfig.rpcUrls.infura.http[0] || - indexedWagmiChains?.[chainID]?.rpcUrls?.public?.http?.[0] || + chainConfig?.rpcUrls.default.http[0] || + chainConfig?.rpcUrls.alchemy.http[0] || + chainConfig?.rpcUrls.infura.http[0] || + indexedWagmiChains[executionChainId]?.rpcUrls?.public?.http?.[0] || '' try { @@ -232,11 +251,11 @@ export function getClient(chainID: number): PublicClient { const headers = { Authorization: `Basic ${btoa(urlAsNodeURL.username + ':' + urlAsNodeURL.password)}` } const cleanUrl = urlAsNodeURL.href.replace(`${urlAsNodeURL.username}:${urlAsNodeURL.password}@`, '') return createPublicClient({ - chain: indexedWagmiChains[chainID], + chain: indexedWagmiChains[executionChainId], transport: http(cleanUrl, { fetchOptions: { headers } }) }) } - return createPublicClient({ chain: indexedWagmiChains[chainID], transport: http(url) }) + return createPublicClient({ chain: indexedWagmiChains[executionChainId], transport: http(url) }) } catch { throw new Error(`We couldn't get a valid RPC URL for chain ${chainID}`) } diff --git a/src/config/chainDefinitions.ts b/src/config/chainDefinitions.ts new file mode 100644 index 000000000..41759dd6d --- /dev/null +++ b/src/config/chainDefinitions.ts @@ -0,0 +1,32 @@ +import { defineChain } from 'viem' +import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' + +export const katana = defineChain({ + id: 747474, + name: 'Katana', + nativeCurrency: { + decimals: 18, + name: 'Ether', + symbol: 'ETH' + }, + rpcUrls: { + default: { http: ['https://rpc.katanarpc.com'] } + }, + blockExplorers: { + default: { + name: 'Katana Explorer', + url: 'https://katanascan.com' + } + }, + contracts: { + multicall3: { + address: '0xcA11bde05977b3631167028862bE2a173976CA11', + blockCreated: 1898013 + } + }, + testnet: false +}) + +export const canonicalChains = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] as const + +export type TCanonicalChainId = (typeof canonicalChains)[number]['id'] diff --git a/src/config/supportedChains.test.ts b/src/config/supportedChains.test.ts new file mode 100644 index 000000000..8f9477888 --- /dev/null +++ b/src/config/supportedChains.test.ts @@ -0,0 +1,22 @@ +import type { Chain } from 'viem' +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('supportedChains exports', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('./tenderly') + }) + + it('keeps app chains canonical while wallet chains can use execution IDs', async () => { + vi.doMock('./tenderly', () => ({ + supportedCanonicalChains: [{ id: 1, name: 'Ethereum' } satisfies Partial as Chain], + supportedExecutionChains: [{ id: 73571, name: 'Ethereum Tenderly' } satisfies Partial as Chain] + })) + + const module = await import('./supportedChains') + + expect(module.supportedChains.map((chain) => chain.id)).toEqual([1]) + expect(module.supportedAppChains.map((chain) => chain.id)).toEqual([1]) + expect(module.supportedWalletChains.map((chain) => chain.id)).toEqual([73571]) + }) +}) diff --git a/src/config/supportedChains.ts b/src/config/supportedChains.ts index beb026a3c..6ba3bf913 100644 --- a/src/config/supportedChains.ts +++ b/src/config/supportedChains.ts @@ -1,5 +1,10 @@ -import { katana } from '@shared/utils/wagmi' -import { arbitrum, base, fantom, mainnet, optimism, polygon, sonic } from 'viem/chains' +import type { Chain } from 'viem' +import type { TCanonicalChainId } from './chainDefinitions' +import { supportedCanonicalChains, supportedExecutionChains } from './tenderly' -export const supportedChains = [mainnet, optimism, polygon, fantom, base, arbitrum, sonic, katana] as const -export type TSupportedChainId = (typeof supportedChains)[number]['id'] +export const supportedChains = supportedCanonicalChains +export const supportedAppChains = supportedCanonicalChains +export const supportedWalletChains = supportedExecutionChains + +export type TSupportedChainId = TCanonicalChainId +export type TSupportedChain = Chain diff --git a/src/config/tenderly.test.ts b/src/config/tenderly.test.ts new file mode 100644 index 000000000..a80c1d14d --- /dev/null +++ b/src/config/tenderly.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { canonicalChains } from './chainDefinitions' +import { + getSupportedCanonicalChainsForRuntime, + getSupportedChainLookupForRuntime, + getSupportedExecutionChainsForRuntime, + isConnectedToExecutionChainForRuntime, + parseTenderlyRuntime, + resolveCanonicalChainIdForRuntime, + resolveConnectedCanonicalChainIdForRuntime, + resolveExecutionChainIdForRuntime, + resolveTenderlyExplorerUriForExecutionChainIdForRuntime, + resolveTenderlyRpcUriForExecutionChainIdForRuntime +} from './tenderly' + +describe('parseTenderlyRuntime', () => { + it('returns the canonical chain set when Tenderly mode is disabled', () => { + const runtime = parseTenderlyRuntime({}) + const canonicalChainIds = canonicalChains.map((chain) => chain.id) + + expect(runtime.isEnabled).toBe(false) + expect(getSupportedCanonicalChainsForRuntime(runtime).map((chain) => chain.id)).toEqual(canonicalChainIds) + expect(resolveExecutionChainIdForRuntime(runtime, 1)).toBe(1) + expect(resolveExecutionChainIdForRuntime(runtime, 1337)).toBe(1337) + expect(resolveCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) + expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) + expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1)).toBe(1) + expect(isConnectedToExecutionChainForRuntime(runtime, 1, 1)).toBe(true) + }) + + it('requires both chain id and rpc uri when a Tenderly chain is configured', () => { + expect(() => + parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '73571' + }) + ).toThrow(/requires both VITE_TENDERLY_CHAIN_ID_FOR_1 and VITE_TENDERLY_RPC_URI_FOR_1/) + }) + + it('rejects duplicate Tenderly execution chain ids', () => { + expect(() => + parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example', + VITE_TENDERLY_CHAIN_ID_FOR_10: '73571', + VITE_TENDERLY_RPC_URI_FOR_10: 'https://rpc.tenderly.optimism.example' + }) + ).toThrow(/Duplicate Tenderly execution chain ID 73571 configured for canonical chains 1 and 10/) + }) + + it('filters canonical chains and resolves execution chain ids from runtime config', () => { + const runtime = parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example', + VITE_TENDERLY_EXPLORER_URI_FOR_1: 'https://explorer.tenderly.ethereum.example', + VITE_TENDERLY_CHAIN_ID_FOR_10: '73572', + VITE_TENDERLY_RPC_URI_FOR_10: 'https://rpc.tenderly.optimism.example' + }) + + const supportedCanonicalChains = getSupportedCanonicalChainsForRuntime(runtime) + const supportedExecutionChains = getSupportedExecutionChainsForRuntime(runtime, supportedCanonicalChains) + const supportedLookupChains = getSupportedChainLookupForRuntime( + runtime, + supportedCanonicalChains, + supportedExecutionChains + ) + + expect(runtime.configuredCanonicalChainIds).toEqual([1, 10]) + expect(supportedCanonicalChains.map((chain) => chain.id)).toEqual([1, 10]) + expect(supportedCanonicalChains[0].rpcUrls.default.http[0]).toBe('https://rpc.tenderly.ethereum.example') + expect(supportedCanonicalChains[0].blockExplorers?.default.url).toBe('https://explorer.tenderly.ethereum.example') + + expect(supportedExecutionChains.map((chain) => chain.id)).toEqual([73571, 73572]) + expect(supportedExecutionChains[0].name).toContain('Tenderly') + expect(supportedExecutionChains[0].testnet).toBe(true) + + expect(supportedLookupChains.map((chain) => chain.id)).toEqual([1, 10, 73571, 73572]) + + expect(resolveExecutionChainIdForRuntime(runtime, 1)).toBe(73571) + expect(resolveExecutionChainIdForRuntime(runtime, 73571)).toBe(73571) + expect(resolveExecutionChainIdForRuntime(runtime, 1337)).toBe(1337) + expect(resolveExecutionChainIdForRuntime(runtime, 8453)).toBeUndefined() + + expect(resolveCanonicalChainIdForRuntime(runtime, 1)).toBe(1) + expect(resolveCanonicalChainIdForRuntime(runtime, 73571)).toBe(1) + expect(resolveCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) + expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 73571)).toBe(1) + expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) + expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1)).toBe(1) + expect(resolveTenderlyRpcUriForExecutionChainIdForRuntime(runtime, 73571)).toBe( + 'https://rpc.tenderly.ethereum.example' + ) + expect(resolveTenderlyExplorerUriForExecutionChainIdForRuntime(runtime, 73571)).toBe( + 'https://explorer.tenderly.ethereum.example' + ) + expect(resolveTenderlyRpcUriForExecutionChainIdForRuntime(runtime, 1)).toBeUndefined() + expect(isConnectedToExecutionChainForRuntime(runtime, 1, 1)).toBe(false) + expect(isConnectedToExecutionChainForRuntime(runtime, 73571, 1)).toBe(true) + expect(isConnectedToExecutionChainForRuntime(runtime, 1, 73571)).toBe(false) + expect(isConnectedToExecutionChainForRuntime(runtime, 73571, 73571)).toBe(true) + }) + + it('does not reuse canonical explorers for execution chains without explicit Tenderly explorer URIs', () => { + const runtime = parseTenderlyRuntime({ + VITE_TENDERLY_MODE: 'true', + VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', + VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example' + }) + const supportedCanonicalChains = getSupportedCanonicalChainsForRuntime(runtime) + const supportedExecutionChains = getSupportedExecutionChainsForRuntime(runtime, supportedCanonicalChains) + + expect(supportedCanonicalChains[0].blockExplorers?.default.url).toBeTruthy() + expect(supportedExecutionChains[0].id).toBe(73571) + expect(supportedExecutionChains[0].blockExplorers).toBeUndefined() + expect(resolveTenderlyExplorerUriForExecutionChainIdForRuntime(runtime, 73571)).toBeUndefined() + }) +}) diff --git a/src/config/tenderly.ts b/src/config/tenderly.ts new file mode 100644 index 000000000..3131ba502 --- /dev/null +++ b/src/config/tenderly.ts @@ -0,0 +1,403 @@ +import type { Chain } from 'viem' +import { canonicalChains, type TCanonicalChainId } from './chainDefinitions' + +type TEnvValue = boolean | string | undefined + +type TTenderlyEnv = Record + +export type TTenderlyChainConfig = { + canonicalChainId: TCanonicalChainId + executionChainId: number + rpcUri: string + explorerUri?: string +} + +export type TTenderlyRuntime = { + isEnabled: boolean + configuredByCanonicalId: Readonly> + configuredCanonicalChainIds: readonly TCanonicalChainId[] + canonicalToExecutionChainId: ReadonlyMap + executionToCanonicalChainId: ReadonlyMap +} + +// Legacy localhost fork support is intentionally limited to 1337. +// The older 5402 alias is retired unless we explicitly revive that workflow. +const LOCAL_EXECUTION_CHAIN_ALIASES = new Map([[1337, 1]]) + +function readEnvString(value: TEnvValue): string { + if (typeof value === 'string') { + return value.trim() + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + return '' +} + +function isTruthyEnvValue(value: TEnvValue): boolean { + return ['1', 'on', 'true', 'yes'].includes(readEnvString(value).toLowerCase()) +} + +function withTenderlyOverrides(chain: Chain, config?: TTenderlyChainConfig): Chain { + if (!config) { + return chain + } + + const defaultHttp = [config.rpcUri, ...(chain.rpcUrls.default?.http || [])].filter(Boolean) + const publicHttp = [config.rpcUri, ...(chain.rpcUrls.public?.http || chain.rpcUrls.default?.http || [])].filter( + Boolean + ) + + return { + ...chain, + rpcUrls: { + ...chain.rpcUrls, + default: { + ...chain.rpcUrls.default, + http: [...new Set(defaultHttp)] + }, + public: { + ...chain.rpcUrls.public, + http: [...new Set(publicHttp)] + } + }, + blockExplorers: + config.explorerUri && chain.blockExplorers + ? { + ...chain.blockExplorers, + default: { + ...chain.blockExplorers.default, + url: config.explorerUri + } + } + : chain.blockExplorers + } +} + +function buildExecutionChain(chain: Chain, config: TTenderlyChainConfig): Chain { + return { + ...withTenderlyOverrides(chain, config), + id: config.executionChainId, + name: `${chain.name} Tenderly`, + blockExplorers: + config.explorerUri && chain.blockExplorers + ? { + ...chain.blockExplorers, + default: { + name: `${chain.name} Tenderly Explorer`, + url: config.explorerUri + } + } + : undefined, + testnet: true + } +} + +export function parseTenderlyRuntime(env: TTenderlyEnv): TTenderlyRuntime { + const isEnabled = isTruthyEnvValue(env.VITE_TENDERLY_MODE) + + if (!isEnabled) { + return { + isEnabled: false, + configuredByCanonicalId: {}, + configuredCanonicalChainIds: [], + canonicalToExecutionChainId: new Map(), + executionToCanonicalChainId: new Map() + } + } + + const configuredChains = canonicalChains.reduce((accumulator, chain) => { + const rawExecutionChainId = readEnvString(env[`VITE_TENDERLY_CHAIN_ID_FOR_${chain.id}`]) + const rawRpcUri = readEnvString(env[`VITE_TENDERLY_RPC_URI_FOR_${chain.id}`]) + const rawExplorerUri = readEnvString(env[`VITE_TENDERLY_EXPLORER_URI_FOR_${chain.id}`]) + const hasAnyConfig = Boolean(rawExecutionChainId || rawRpcUri || rawExplorerUri) + + if (!hasAnyConfig) { + return accumulator + } + + if (!rawExecutionChainId || !rawRpcUri) { + throw new Error( + `Tenderly chain ${chain.id} requires both VITE_TENDERLY_CHAIN_ID_FOR_${chain.id} and VITE_TENDERLY_RPC_URI_FOR_${chain.id}` + ) + } + + const executionChainId = Number(rawExecutionChainId) + if (!Number.isInteger(executionChainId) || executionChainId <= 0) { + throw new Error(`Invalid Tenderly execution chain ID for canonical chain ${chain.id}: ${rawExecutionChainId}`) + } + + accumulator.push({ + canonicalChainId: chain.id, + executionChainId, + rpcUri: rawRpcUri, + explorerUri: rawExplorerUri || undefined + }) + + return accumulator + }, []) + + if (configuredChains.length === 0) { + throw new Error('Tenderly mode is enabled but no Tenderly execution chains are configured') + } + + const executionToCanonicalChainId = configuredChains.reduce>((accumulator, chain) => { + const existingCanonicalChainId = accumulator.get(chain.executionChainId) + if (existingCanonicalChainId !== undefined) { + throw new Error( + `Duplicate Tenderly execution chain ID ${chain.executionChainId} configured for canonical chains ${existingCanonicalChainId} and ${chain.canonicalChainId}` + ) + } + + accumulator.set(chain.executionChainId, chain.canonicalChainId) + return accumulator + }, new Map()) + + const configuredByCanonicalId = configuredChains.reduce>( + (accumulator, chain) => { + accumulator[chain.canonicalChainId] = chain + return accumulator + }, + {} + ) + + return { + isEnabled: true, + configuredByCanonicalId, + configuredCanonicalChainIds: configuredChains.map((chain) => chain.canonicalChainId), + canonicalToExecutionChainId: new Map( + configuredChains.map((chain) => [chain.canonicalChainId, chain.executionChainId]) + ), + executionToCanonicalChainId + } +} + +export const tenderlyRuntime = parseTenderlyRuntime(import.meta.env) + +export function getSupportedCanonicalChainsForRuntime(runtime: TTenderlyRuntime): readonly Chain[] { + return runtime.isEnabled + ? canonicalChains + .filter((chain) => chain.id in runtime.configuredByCanonicalId) + .map((chain) => withTenderlyOverrides(chain, runtime.configuredByCanonicalId[chain.id])) + : canonicalChains +} + +export function getSupportedExecutionChainsForRuntime( + runtime: TTenderlyRuntime, + canonicalChainSet: readonly Chain[] = getSupportedCanonicalChainsForRuntime(runtime) +): readonly Chain[] { + return runtime.isEnabled + ? canonicalChainSet.map((chain) => + buildExecutionChain(chain, runtime.configuredByCanonicalId[chain.id] as TTenderlyChainConfig) + ) + : canonicalChainSet +} + +export function getSupportedChainLookupForRuntime( + runtime: TTenderlyRuntime, + canonicalChainSet: readonly Chain[] = getSupportedCanonicalChainsForRuntime(runtime), + executionChainSet: readonly Chain[] = getSupportedExecutionChainsForRuntime(runtime, canonicalChainSet) +): readonly Chain[] { + return [ + ...canonicalChainSet, + ...executionChainSet.filter((chain) => !canonicalChainSet.some((canonicalChain) => canonicalChain.id === chain.id)) + ] +} + +export const supportedCanonicalChains: readonly Chain[] = getSupportedCanonicalChainsForRuntime(tenderlyRuntime) + +export const supportedExecutionChains: readonly Chain[] = getSupportedExecutionChainsForRuntime( + tenderlyRuntime, + supportedCanonicalChains +) + +export const supportedChainLookup: readonly Chain[] = getSupportedChainLookupForRuntime( + tenderlyRuntime, + supportedCanonicalChains, + supportedExecutionChains +) + +export function isTenderlyModeEnabled(): boolean { + return tenderlyRuntime.isEnabled +} + +export function getTenderlyBackedCanonicalChainIds(): readonly TCanonicalChainId[] { + return tenderlyRuntime.configuredCanonicalChainIds +} + +export function isCanonicalChainEnabled(chainId: number): chainId is TCanonicalChainId { + return supportedCanonicalChains.some((chain) => chain.id === chainId) +} + +export function getCanonicalChain(chainId: number): Chain | undefined { + return supportedCanonicalChains.find((chain) => chain.id === chainId) +} + +function isCanonicalChainEnabledForRuntime(runtime: TTenderlyRuntime, chainId: number): chainId is TCanonicalChainId { + return getSupportedCanonicalChainsForRuntime(runtime).some((chain) => chain.id === chainId) +} + +function resolveLocalExecutionCanonicalAliasForRuntime( + runtime: TTenderlyRuntime, + chainId: number +): TCanonicalChainId | undefined { + const canonicalChainId = LOCAL_EXECUTION_CHAIN_ALIASES.get(chainId) + if (canonicalChainId === undefined) { + return undefined + } + + return isCanonicalChainEnabledForRuntime(runtime, canonicalChainId) ? canonicalChainId : undefined +} + +export function resolveCanonicalChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): TCanonicalChainId | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const executionChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (executionChainId !== undefined) { + return executionChainId + } + + const localExecutionAlias = resolveLocalExecutionCanonicalAliasForRuntime(runtime, chainId as number) + if (localExecutionAlias !== undefined) { + return localExecutionAlias + } + + if (isCanonicalChainEnabledForRuntime(runtime, chainId as number)) { + return chainId as TCanonicalChainId + } + + return undefined +} + +export function resolveCanonicalChainId(chainId: number | undefined): TCanonicalChainId | undefined { + return resolveCanonicalChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveConnectedCanonicalChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): TCanonicalChainId | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const localExecutionAlias = resolveLocalExecutionCanonicalAliasForRuntime(runtime, chainId as number) + if (localExecutionAlias !== undefined) { + return localExecutionAlias + } + + if (runtime.isEnabled) { + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId !== undefined) { + return canonicalChainId + } + + return isCanonicalChainEnabledForRuntime(runtime, chainId as number) ? (chainId as TCanonicalChainId) : undefined + } + + return isCanonicalChainEnabledForRuntime(runtime, chainId as number) ? (chainId as TCanonicalChainId) : undefined +} + +export function resolveConnectedCanonicalChainId(chainId: number | undefined): TCanonicalChainId | undefined { + return resolveConnectedCanonicalChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): number | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const normalizedChainId = chainId as number + + if (resolveLocalExecutionCanonicalAliasForRuntime(runtime, normalizedChainId) !== undefined) { + return normalizedChainId + } + + if (runtime.executionToCanonicalChainId.has(normalizedChainId)) { + return normalizedChainId + } + + if (runtime.isEnabled) { + return runtime.canonicalToExecutionChainId.get(normalizedChainId) + } + + return isCanonicalChainEnabledForRuntime(runtime, normalizedChainId) ? normalizedChainId : undefined +} + +export function resolveExecutionChainId(chainId: number | undefined): number | undefined { + return resolveExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function isConnectedToExecutionChainForRuntime( + runtime: TTenderlyRuntime, + connectedChainId: number | undefined, + targetChainId: number | undefined +): boolean { + if (!Number.isInteger(connectedChainId)) { + return false + } + + const requiredExecutionChainId = resolveExecutionChainIdForRuntime(runtime, targetChainId) + if (requiredExecutionChainId === undefined) { + return false + } + + return connectedChainId === requiredExecutionChainId +} + +export function isConnectedToExecutionChain( + connectedChainId: number | undefined, + targetChainId: number | undefined +): boolean { + return isConnectedToExecutionChainForRuntime(tenderlyRuntime, connectedChainId, targetChainId) +} + +export function resolveTenderlyRpcUriForExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): string | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId === undefined) { + return undefined + } + + return runtime.configuredByCanonicalId[canonicalChainId]?.rpcUri +} + +export function resolveTenderlyRpcUriForExecutionChainId(chainId: number | undefined): string | undefined { + return resolveTenderlyRpcUriForExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} + +export function resolveTenderlyExplorerUriForExecutionChainIdForRuntime( + runtime: TTenderlyRuntime, + chainId: number | undefined +): string | undefined { + if (!Number.isInteger(chainId)) { + return undefined + } + + const canonicalChainId = runtime.executionToCanonicalChainId.get(chainId as number) + if (canonicalChainId === undefined) { + return undefined + } + + return runtime.configuredByCanonicalId[canonicalChainId]?.explorerUri +} + +export function resolveTenderlyExplorerUriForExecutionChainId(chainId: number | undefined): string | undefined { + return resolveTenderlyExplorerUriForExecutionChainIdForRuntime(tenderlyRuntime, chainId) +} diff --git a/src/config/wagmi.ts b/src/config/wagmi.ts index 4ab41a3a8..902c6c820 100644 --- a/src/config/wagmi.ts +++ b/src/config/wagmi.ts @@ -1,4 +1,4 @@ -import { getNetwork, getRpcUriFor, registerConfig } from '@shared/utils/wagmi' +import { registerConfig } from '@shared/utils/wagmi' import { connectorsForWallets } from '@rainbow-me/rainbowkit' import { frameWallet, @@ -9,9 +9,10 @@ import { safeWallet, walletConnectWallet } from '@rainbow-me/rainbowkit/wallets' -import type { Transport } from 'viem' -import { cookieStorage, createConfig, createStorage, fallback, http } from 'wagmi' -import { supportedChains, type TSupportedChainId } from './supportedChains' +import { cookieStorage, createConfig, createStorage } from 'wagmi' +import { supportedAppChains, supportedWalletChains } from './supportedChains' +import { getWagmiConfigChains } from './wagmiChains' +import { buildTransports } from './wagmiTransports' const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string const appName = (import.meta.env.VITE_WALLETCONNECT_PROJECT_NAME as string) || 'Yearn Finance' @@ -34,39 +35,12 @@ const connectors = connectorsForWallets( { projectId, appName } ) -function buildTransports(): Record { - const transports = {} as Record - - for (const chain of supportedChains) { - const network = getNetwork(chain.id) - const availableTransports: Transport[] = [] - - if (network?.defaultRPC) { - availableTransports.push(http(network.defaultRPC, { batch: true })) - } - - const envRPC = getRpcUriFor(chain.id) - if (envRPC) { - availableTransports.push(http(envRPC, { batch: true })) - } - - const publicRPC = chain.rpcUrls?.default?.http?.[0] - if (publicRPC && !availableTransports.length) { - availableTransports.push(http(publicRPC, { batch: true })) - } - - availableTransports.push(http()) - - transports[chain.id as TSupportedChainId] = fallback(availableTransports) - } - - return transports -} +const wagmiChains = getWagmiConfigChains(supportedWalletChains, supportedAppChains) export const wagmiConfig = createConfig({ - chains: supportedChains, + chains: wagmiChains, connectors, - transports: buildTransports(), + transports: buildTransports(wagmiChains), storage: createStorage({ storage: cookieStorage }), ssr: true }) diff --git a/src/config/wagmiChains.test.ts b/src/config/wagmiChains.test.ts new file mode 100644 index 000000000..497cd95e1 --- /dev/null +++ b/src/config/wagmiChains.test.ts @@ -0,0 +1,47 @@ +import type { Chain } from 'viem' +import { base, mainnet } from 'viem/chains' +import { describe, expect, it } from 'vitest' +import { getWagmiConfigChains } from './wagmiChains' + +describe('getWagmiConfigChains', () => { + it('keeps the real canonical mainnet when ethereum is part of the supported canonical set', () => { + const tenderlyMainnet = { ...mainnet, id: 73571, name: 'Tenderly Mainnet' } as Chain + const canonicalMainnet = { + ...mainnet, + rpcUrls: { ...mainnet.rpcUrls, default: { http: ['https://rpc.tenderly.ethereum.example'] } } + } as Chain + + expect(getWagmiConfigChains([tenderlyMainnet], [canonicalMainnet]).map((chain) => chain.id)).toEqual([73571, 1]) + expect(getWagmiConfigChains([tenderlyMainnet], [canonicalMainnet])[1].rpcUrls.default.http[0]).toBe( + mainnet.rpcUrls.default.http[0] + ) + }) + + it('does not duplicate canonical mainnet when it is already configured', () => { + const tenderlyMainnet = { ...mainnet, id: 73571, name: 'Tenderly Mainnet' } as Chain + + expect(getWagmiConfigChains([mainnet, tenderlyMainnet]).map((chain) => chain.id)).toEqual([1, 73571]) + }) + + it('keeps canonical non-mainnet chains alongside Tenderly execution chains', () => { + const tenderlyBase = { ...base, id: 84531, name: 'Base Tenderly' } as Chain + + expect(getWagmiConfigChains([tenderlyBase], [base]).map((chain) => chain.id)).toEqual([84531, 8453]) + }) + + it('drops Tenderly-overridden canonical mainnet in favor of the real mainnet entry', () => { + const tenderlyMainnet = { + ...mainnet, + rpcUrls: { ...mainnet.rpcUrls, default: { http: ['https://rpc.tenderly.example'] } } + } as Chain + + expect(getWagmiConfigChains([], [tenderlyMainnet]).map((chain) => chain.id)).toEqual([1]) + expect(getWagmiConfigChains([], [tenderlyMainnet])[0].rpcUrls.default.http[0]).toBe(mainnet.rpcUrls.default.http[0]) + }) + + it('does not advertise canonical mainnet when ethereum is not part of the supported canonical set', () => { + const tenderlyBase = { ...base, id: 84531, name: 'Base Tenderly' } as Chain + + expect(getWagmiConfigChains([tenderlyBase], [base]).some((chain) => chain.id === mainnet.id)).toBe(false) + }) +}) diff --git a/src/config/wagmiChains.ts b/src/config/wagmiChains.ts new file mode 100644 index 000000000..6baf89d86 --- /dev/null +++ b/src/config/wagmiChains.ts @@ -0,0 +1,19 @@ +import type { Chain } from 'viem' +import { mainnet } from 'viem/chains' + +function normalizeCanonicalChain(chain: Chain): Chain { + return chain.id === mainnet.id ? mainnet : chain +} + +export function getWagmiConfigChains( + walletChains: readonly Chain[], + canonicalChains: readonly Chain[] = [] +): [Chain, ...Chain[]] { + const uniqueChains = Array.from( + new Map( + [...walletChains, ...canonicalChains.map(normalizeCanonicalChain)].map((chain) => [chain.id, chain]) + ).values() + ) + + return uniqueChains as [Chain, ...Chain[]] +} diff --git a/src/config/wagmiTransports.test.ts b/src/config/wagmiTransports.test.ts new file mode 100644 index 000000000..8d46cde59 --- /dev/null +++ b/src/config/wagmiTransports.test.ts @@ -0,0 +1,37 @@ +import { base, mainnet } from 'viem/chains' +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('getTransportRpcUrlsForChain', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('@shared/utils/wagmi') + }) + + it('preserves the real mainnet transport for the canonical wagmi mainnet chain', async () => { + vi.doMock('@shared/utils/wagmi', () => ({ + getNetwork: (chainId: number) => ({ + defaultRPC: chainId === 1 ? 'https://rpc.tenderly.ethereum.example' : '' + }), + getRpcUriFor: () => '', + registerConfig: () => undefined + })) + + const { getTransportRpcUrlsForChain } = await import('./wagmiTransports') + + expect(getTransportRpcUrlsForChain(mainnet)).toEqual([mainnet.rpcUrls.default.http[0]]) + }) + + it('keeps the indexed Tenderly transport for non-mainnet chains', async () => { + vi.doMock('@shared/utils/wagmi', () => ({ + getNetwork: (chainId: number) => ({ + defaultRPC: chainId === base.id ? 'https://rpc.tenderly.base.example' : '' + }), + getRpcUriFor: () => '', + registerConfig: () => undefined + })) + + const { getTransportRpcUrlsForChain } = await import('./wagmiTransports') + + expect(getTransportRpcUrlsForChain(base)[0]).toBe('https://rpc.tenderly.base.example') + }) +}) diff --git a/src/config/wagmiTransports.ts b/src/config/wagmiTransports.ts new file mode 100644 index 000000000..54d519b19 --- /dev/null +++ b/src/config/wagmiTransports.ts @@ -0,0 +1,41 @@ +import { getNetwork, getRpcUriFor } from '@shared/utils/wagmi' +import type { Chain, Transport } from 'viem' +import { mainnet } from 'viem/chains' +import { fallback, http } from 'wagmi' + +function getLegacyRpcUri(chainId: number): string { + const value = import.meta.env.VITE_JSON_RPC_URI?.[chainId] || import.meta.env.VITE_JSON_RPC_URL?.[chainId] + return typeof value === 'string' ? value.trim() : '' +} + +function isCanonicalMainnetChain(chain: Chain): boolean { + return chain.id === mainnet.id && chain.rpcUrls.default.http[0] === mainnet.rpcUrls.default.http[0] +} + +export function getTransportRpcUrlsForChain(chain: Chain): string[] { + const indexedNetwork = isCanonicalMainnetChain(chain) ? undefined : getNetwork(chain.id) + + return [ + ...new Set( + [ + indexedNetwork?.defaultRPC, + getRpcUriFor(chain.id), + getLegacyRpcUri(chain.id), + chain.rpcUrls.default.http[0], + chain.rpcUrls.public?.http?.[0] + ].filter((value): value is string => Boolean(value)) + ) + ] +} + +export function buildTransports(chains: readonly Chain[]): Record { + const transports: Record = {} + + for (const chain of chains) { + const availableTransports = getTransportRpcUrlsForChain(chain).map((rpcUrl) => http(rpcUrl, { batch: true })) + availableTransports.push(http()) + transports[chain.id] = fallback(availableTransports) + } + + return transports +} diff --git a/src/context/ChainsProvider.tsx b/src/context/ChainsProvider.tsx index f84a50518..9ac208a8a 100644 --- a/src/context/ChainsProvider.tsx +++ b/src/context/ChainsProvider.tsx @@ -2,17 +2,14 @@ import type { FC, PropsWithChildren } from 'react' import { useMemo } from 'react' import { useLocation } from 'react-router' import { useAccount, useChainId, useSwitchChain } from 'wagmi' -import { supportedChains, type TSupportedChainId } from '@/config/supportedChains' +import { supportedAppChains, type TSupportedChainId } from '@/config/supportedChains' +import { getCanonicalChain, resolveConnectedCanonicalChainId, resolveExecutionChainId } from '@/config/tenderly' import { chainsContext, type TChainsContext } from '@/context/chainsContext' const DEFAULT_CHAIN_ID = 1 as TSupportedChainId -function getSupportedChain(chainId: number) { - return supportedChains.find((chain: { id: number }) => chain.id === chainId) -} - export const ChainsProvider: FC = ({ children }) => { - const chainId = useChainId() + const rawChainId = useChainId() const { pathname } = useLocation() const account = useAccount() const { switchChain } = useSwitchChain() @@ -23,21 +20,32 @@ export const ChainsProvider: FC = ({ children }) => { }, [pathname]) const contextValue = useMemo((): TChainsContext => { - const isConnectedChainValid = !!(account.chain?.id && getSupportedChain(account.chain.id)) - const chainIdIntent = (chainIdParam || chainId || DEFAULT_CHAIN_ID) as TSupportedChainId + const connectedCanonicalChainId = resolveConnectedCanonicalChainId(account.chain?.id) + const resolvedCanonicalChainId = resolveConnectedCanonicalChainId(rawChainId) ?? DEFAULT_CHAIN_ID + const chainIdIntent = ( + chainIdParam && getCanonicalChain(chainIdParam)?.id ? chainIdParam : resolvedCanonicalChainId + ) as TSupportedChainId + const executionChainId = resolveExecutionChainId(chainIdIntent) ?? resolveExecutionChainId(resolvedCanonicalChainId) return { - chains: supportedChains, - chainId: (chainId || DEFAULT_CHAIN_ID) as TSupportedChainId, + chains: supportedAppChains, + chainId: resolvedCanonicalChainId, chainIdIntent, - getChainFromId: getSupportedChain, + executionChainId: executionChainId ?? resolvedCanonicalChainId, + getChainFromId: getCanonicalChain, + getExecutionChainId: resolveExecutionChainId, switchNetwork: (newChainId: number) => { - switchChain?.({ chainId: newChainId }) + const nextExecutionChainId = resolveExecutionChainId(newChainId) + if (nextExecutionChainId === undefined) { + console.warn(`Chain ${newChainId} is not enabled for execution`) + return + } + switchChain?.({ chainId: nextExecutionChainId }) }, - isConnectedChainValid, + isConnectedChainValid: Boolean(connectedCanonicalChainId), isConnected: !!account.address } - }, [chainId, chainIdParam, account.chain?.id, account.address, switchChain]) + }, [rawChainId, chainIdParam, account.chain?.id, account.address, switchChain]) return {children} } diff --git a/src/context/chainsContext.ts b/src/context/chainsContext.ts index aa0f18a28..96cba76b4 100644 --- a/src/context/chainsContext.ts +++ b/src/context/chainsContext.ts @@ -6,7 +6,9 @@ export type TChainsContext = { chains: readonly Chain[] chainId: TSupportedChainId chainIdIntent: TSupportedChainId + executionChainId: number getChainFromId: (chainId: number) => Chain | undefined + getExecutionChainId: (chainId: number) => number | undefined switchNetwork: (chainId: number) => void isConnectedChainValid: boolean isConnected: boolean From d88c50604c9efa3ce1456462e68a6f6351306df5 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:41:13 +0000 Subject: [PATCH 03/15] chore: init From 40b03a1dcaf41b742aa91810450ab017269aa85d Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:27:47 -0400 Subject: [PATCH 04/15] Add vault docs links and yvUSD API row to More Info (#1111) * add link to API * add docs link to more info --- .../detail/VaultInfoSection.test.ts | 57 +++++++- .../components/detail/VaultInfoSection.tsx | 128 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts b/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts index ef2e9d200..c5daebc3e 100644 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts +++ b/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts @@ -1,8 +1,63 @@ +import { YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' +import { YVUSD_LOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { describe, expect, it } from 'vitest' -import { extractCurvePools, resolveCurveDepositUrl } from './VaultInfoSection' +import { extractCurvePools, getVaultDocsLinks, resolveCurveDepositUrl } from './VaultInfoSection' const TOKEN_ADDRESS = '0x1111111111111111111111111111111111111111' const POOL_ADDRESS = '0x2222222222222222222222222222222222222222' +const DEFAULT_VAULT_ADDRESS = '0x3333333333333333333333333333333333333333' + +function createVaultTokenAddress(address = DEFAULT_VAULT_ADDRESS): `0x${string}` { + return address as `0x${string}` +} + +describe('getVaultDocsLinks', () => { + it('returns yvUSD docs by address', () => { + expect(getVaultDocsLinks(YVUSD_LOCKED_ADDRESS, 'yvUSD', '2')).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/yvusd/', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/yvusd' + }) + }) + + it('returns yBOLD docs by address', () => { + expect(getVaultDocsLinks(YBOLD_VAULT_ADDRESS, 'yBOLD', '2')).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/v3/overview', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/yBold' + }) + }) + + it('returns yCRV docs by symbol', () => { + expect( + getVaultDocsLinks(createVaultTokenAddress('0x1111111111111111111111111111111111111111'), 'ycrv', '2') + ).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/ylockers/ycrv/overview' + }) + }) + + it('returns yYB docs by symbol', () => { + expect( + getVaultDocsLinks(createVaultTokenAddress('0x1111111111111111111111111111111111111112'), 'yyb', '2') + ).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/ylockers/yyb/overview' + }) + }) + + it('returns v3 docs by version fallback', () => { + expect(getVaultDocsLinks(DEFAULT_VAULT_ADDRESS as `0x${string}`, 'some-symbol', '3')).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/v3/overview', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/v3' + }) + }) + + it('returns v2 docs by version fallback', () => { + expect(getVaultDocsLinks(DEFAULT_VAULT_ADDRESS as `0x${string}`, 'some-symbol', '2')).toStrictEqual({ + developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', + userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/v2' + }) + }) +}) describe('extractCurvePools', () => { it('extracts pools from canonical data.poolData shape', () => { diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx index a91e7a8dc..4c2f9cef8 100644 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx @@ -6,9 +6,11 @@ import { getVaultInfo, getVaultStaking, getVaultToken, + getVaultVersion, isAutomatedVault, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { isYvUsdAddress, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' @@ -35,8 +37,72 @@ type TCurvePoolsApiResponse = { const CURVE_POOLS_CACHE_TTL_MS = 30 * 60 * 1000 const CURVE_POOLS_CACHE_GC_MS = 60 * 60 * 1000 const CURVE_POOLS_ENDPOINT = 'https://api.curve.finance/v1/getPools/all' +const YVUSD_API_URL = 'https://yvusd-api.yearn.fi/api/aprs' +const USER_DOCS_V2_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/v2' +const USER_DOCS_V3_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/v3' +const DEVELOPER_DOCS_V2_URL = 'https://docs.yearn.fi/developers/building-on-yearn' +const DEVELOPER_DOCS_V3_URL = 'https://docs.yearn.fi/developers/v3/overview' +const USER_DOCS_YVUSD_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/yvusd' +const DEVELOPER_DOCS_YVUSD_URL = 'https://docs.yearn.fi/developers/yvusd/' +const USER_DOCS_YBOLD_URL = 'https://docs.yearn.fi/getting-started/products/yvaults/yBold' +const DEVELOPER_DOCS_YBOLD_URL = DEVELOPER_DOCS_V3_URL +const USER_DOCS_YCRV_URL = 'https://docs.yearn.fi/getting-started/products/ylockers/ycrv/overview' +const DEVELOPER_DOCS_YCRV_URL = DEVELOPER_DOCS_V2_URL +const USER_DOCS_YYB_URL = 'https://docs.yearn.fi/getting-started/products/ylockers/yyb/overview' +const DEVELOPER_DOCS_YYB_URL = DEVELOPER_DOCS_V2_URL const INFO_LABEL_CLASS = 'w-full text-sm text-text-secondary md:w-auto md:pr-4' +export function getVaultDocsLinks( + vaultAddress: `0x${string}`, + tokenSymbol: string, + version: string +): { + userDocumentationUrl: string + developerDocumentationUrl: string +} { + const normalizedSymbol = tokenSymbol.toLowerCase() + + if (isYvUsdAddress(vaultAddress)) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YVUSD_URL, + userDocumentationUrl: USER_DOCS_YVUSD_URL + } + } + + if (vaultAddress === YBOLD_VAULT_ADDRESS) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YBOLD_URL, + userDocumentationUrl: USER_DOCS_YBOLD_URL + } + } + + if (normalizedSymbol === 'ycrv') { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YCRV_URL, + userDocumentationUrl: USER_DOCS_YCRV_URL + } + } + + if (normalizedSymbol === 'yyb') { + return { + developerDocumentationUrl: DEVELOPER_DOCS_YYB_URL, + userDocumentationUrl: USER_DOCS_YYB_URL + } + } + + if (version.startsWith('3') || version.startsWith('~3')) { + return { + developerDocumentationUrl: DEVELOPER_DOCS_V3_URL, + userDocumentationUrl: USER_DOCS_V3_URL + } + } + + return { + developerDocumentationUrl: DEVELOPER_DOCS_V2_URL, + userDocumentationUrl: USER_DOCS_V2_URL + } +} + export function extractCurvePools(payload: unknown): TCurvePoolEntry[] { const poolData = (payload as TCurvePoolsApiResponse | null)?.data?.poolData return Array.isArray(poolData) ? (poolData as TCurvePoolEntry[]) : [] @@ -203,6 +269,12 @@ export function VaultInfoSection({ const liquidityUrl = getLiquidityUrl({ isVelodrome, isAerodrome, tokenAddress: token.address }) const powergloveUrl = `https://powerglove.yearn.fi/vaults/${chainID}/${vaultAddress}` const deployedLabel = getDeployedLabel(inceptTime) + const vaultVersion = getVaultVersion(currentVault) + const { developerDocumentationUrl, userDocumentationUrl } = getVaultDocsLinks( + vaultAddress, + token.symbol, + vaultVersion + ) return (
@@ -308,6 +380,42 @@ export function VaultInfoSection({
) : null} +
+

{'User Documentation'}

+ +
+ +
+

{'Developer Documentation'}

+ +
+

{'Powerglove Analytics Page'}

@@ -326,6 +434,26 @@ export function VaultInfoSection({
+ {isYvUsd ? ( +
+

{'yvUSD API'}

+ +
+ ) : null} +

{'Vault Snapshot Data'}

From 4b36a4e69a1c39cf988244ce40a809bb19a184a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:07:42 -0400 Subject: [PATCH 05/15] chore(deps): bump the minor-updates group across 1 directory with 7 updates (#1132) Bumps the minor-updates group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.21` | `5.95.0` | | [framer-motion](https://github.com/motiondivision/motion) | `12.34.0` | `12.38.0` | | [graphql](https://github.com/graphql/graphql-js) | `16.12.0` | `16.13.1` | | [viem](https://github.com/wevm/viem) | `2.46.1` | `2.47.6` | | [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.18` | `4.2.2` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `16.2.7` | `16.4.0` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.18` | `4.2.2` | Updates `@tanstack/react-query` from 5.90.21 to 5.95.0 - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.95.0/packages/react-query) Updates `framer-motion` from 12.34.0 to 12.38.0 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.34.0...v12.38.0) Updates `graphql` from 16.12.0 to 16.13.1 - [Release notes](https://github.com/graphql/graphql-js/releases) - [Commits](https://github.com/graphql/graphql-js/compare/v16.12.0...v16.13.1) Updates `viem` from 2.46.1 to 2.47.6 - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.46.1...viem@2.47.6) Updates `@tailwindcss/postcss` from 4.1.18 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `lint-staged` from 16.2.7 to 16.4.0 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v16.2.7...v16.4.0) Updates `tailwindcss` from 4.1.18 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@tanstack/react-query" dependency-version: 5.95.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: framer-motion dependency-version: 12.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: graphql dependency-version: 16.13.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: viem dependency-version: 2.47.6 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: lint-staged dependency-version: 16.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bun.lock | 136 +++++++++++++++++++++++++++++++++++++-------------- package.json | 14 +++--- 2 files changed, 107 insertions(+), 43 deletions(-) diff --git a/bun.lock b/bun.lock index 889b7c9ea..d62d536ac 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,12 @@ "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.95.2", "@tanstack/react-virtual": "3.13.18", "cross-fetch": "4.1.0", "ethers": "5.7.2", - "framer-motion": "12.34.0", - "graphql": "16.12.0", + "framer-motion": "12.38.0", + "graphql": "16.13.2", "react": "19.2.4", "react-dom": "19.2.4", "react-hot-toast": "2.6.0", @@ -24,13 +24,13 @@ "recharts": "2.15.4", "use-indexeddb": "2.0.2", "vaul": "1.1.2", - "viem": "2.46.1", + "viem": "2.47.6", "wagmi": "2.18.2", "zod": "4.3.6", }, "devDependencies": { "@biomejs/biome": "2.4.0", - "@tailwindcss/postcss": "4.1.18", + "@tailwindcss/postcss": "4.2.2", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", "@types/node": "20.19.10", @@ -41,9 +41,9 @@ "bump": "0.2.5", "concurrently": "9.2.1", "husky": "9.1.7", - "lint-staged": "16.2.7", + "lint-staged": "16.4.0", "postcss": "8.5.6", - "tailwindcss": "4.1.18", + "tailwindcss": "4.2.2", "typescript": "5.9.3", "vite": "7.3.1", "vite-plugin-webfont-dl": "3.12.0", @@ -492,39 +492,39 @@ "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], @@ -1028,7 +1028,7 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], @@ -1066,7 +1066,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], @@ -1202,7 +1202,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -1328,9 +1328,9 @@ "modern-ahocorasick": ["modern-ahocorasick@1.1.0", "", {}, "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="], - "motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1338,8 +1338,6 @@ "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -1378,7 +1376,7 @@ "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], - "ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "ox": ["ox@0.14.7", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ=="], "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], @@ -1410,8 +1408,6 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], - "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="], @@ -1614,7 +1610,7 @@ "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1628,6 +1624,8 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="], @@ -1718,7 +1716,7 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], - "viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "viem": ["viem@2.47.6", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.7", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], @@ -1784,6 +1782,8 @@ "@base-org/account/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], + "@base-org/account/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "@base-org/account/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], "@coinbase/wallet-sdk/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], @@ -1794,6 +1794,8 @@ "@coinbase/wallet-sdk/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], + "@coinbase/wallet-sdk/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "@coinbase/wallet-sdk/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], "@ethersproject/providers/ws": ["ws@7.4.6", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="], @@ -1832,12 +1834,24 @@ "@reown/appkit/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], + "@reown/appkit/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + + "@reown/appkit-common/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "@reown/appkit-controllers/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], + "@reown/appkit-controllers/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "@reown/appkit-utils/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], + "@reown/appkit-utils/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + "@reown/appkit-wallet/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], + "@safe-global/safe-apps-sdk/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -2006,8 +2020,16 @@ "@base-org/account/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@base-org/account/viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@base-org/account/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "@coinbase/wallet-sdk/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@coinbase/wallet-sdk/viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@coinbase/wallet-sdk/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors": ["@metamask/rpc-errors@6.4.0", "", { "dependencies": { "@metamask/utils": "^9.0.0", "fast-safe-stringify": "^2.0.6" } }, "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils": ["@metamask/utils@8.5.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.0.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ=="], @@ -2034,22 +2056,54 @@ "@metamask/sdk/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "@reown/appkit-common/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], + "@reown/appkit-controllers/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], + "@reown/appkit-utils/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + "@reown/appkit/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], + "@reown/appkit/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + + "@safe-global/safe-apps-sdk/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -2116,6 +2170,8 @@ "@metamask/providers/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@reown/appkit-common/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2126,6 +2182,8 @@ "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], + "@reown/appkit-controllers/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2136,6 +2194,8 @@ "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], + "@reown/appkit-utils/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "@reown/appkit/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2146,6 +2206,10 @@ "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], + "@reown/appkit/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "@safe-global/safe-apps-sdk/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], diff --git a/package.json b/package.json index be6083ea4..3fd12fd7d 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.95.2", "@tanstack/react-virtual": "3.13.18", "cross-fetch": "4.1.0", "ethers": "5.7.2", - "framer-motion": "12.34.0", - "graphql": "16.12.0", + "framer-motion": "12.38.0", + "graphql": "16.13.2", "react": "19.2.4", "react-dom": "19.2.4", "react-hot-toast": "2.6.0", @@ -39,13 +39,13 @@ "recharts": "2.15.4", "use-indexeddb": "2.0.2", "vaul": "1.1.2", - "viem": "2.46.1", + "viem": "2.47.6", "wagmi": "2.18.2", "zod": "4.3.6" }, "devDependencies": { "@biomejs/biome": "2.4.0", - "@tailwindcss/postcss": "4.1.18", + "@tailwindcss/postcss": "4.2.2", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", "@types/node": "20.19.10", @@ -56,9 +56,9 @@ "bump": "0.2.5", "concurrently": "9.2.1", "husky": "9.1.7", - "lint-staged": "16.2.7", + "lint-staged": "16.4.0", "postcss": "8.5.6", - "tailwindcss": "4.1.18", + "tailwindcss": "4.2.2", "typescript": "5.9.3", "vite": "7.3.1", "vite-plugin-webfont-dl": "3.12.0", From f86eaaa46d76a898c08471d7893bc64eda11deeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:38:59 -0400 Subject: [PATCH 06/15] chore(deps-dev): bump @types/node from 20.19.10 to 25.3.0 (#1081) * chore(deps-dev): bump @types/node from 20.19.10 to 25.3.0 * chore(deps-dev): update bun.lock for @types/node bump --------- Co-authored-by: Ross --- bun.lock | 138 ++++++++++++++------------------------------------- package.json | 16 +++--- 2 files changed, 45 insertions(+), 109 deletions(-) diff --git a/bun.lock b/bun.lock index d62d536ac..535717e23 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,12 @@ "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", - "@tanstack/react-query": "5.95.2", + "@tanstack/react-query": "5.90.21", "@tanstack/react-virtual": "3.13.18", "cross-fetch": "4.1.0", "ethers": "5.7.2", - "framer-motion": "12.38.0", - "graphql": "16.13.2", + "framer-motion": "12.34.0", + "graphql": "16.12.0", "react": "19.2.4", "react-dom": "19.2.4", "react-hot-toast": "2.6.0", @@ -24,16 +24,16 @@ "recharts": "2.15.4", "use-indexeddb": "2.0.2", "vaul": "1.1.2", - "viem": "2.47.6", + "viem": "2.46.1", "wagmi": "2.18.2", "zod": "4.3.6", }, "devDependencies": { "@biomejs/biome": "2.4.0", - "@tailwindcss/postcss": "4.2.2", + "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", - "@types/node": "20.19.10", + "@types/node": "25.3.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vercel/node": "5.6.3", @@ -41,9 +41,9 @@ "bump": "0.2.5", "concurrently": "9.2.1", "husky": "9.1.7", - "lint-staged": "16.4.0", + "lint-staged": "16.2.7", "postcss": "8.5.6", - "tailwindcss": "4.2.2", + "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", "vite-plugin-webfont-dl": "3.12.0", @@ -492,39 +492,39 @@ "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], - "@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], - "@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], @@ -582,7 +582,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ=="], + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -1028,7 +1028,7 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], "fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], @@ -1066,7 +1066,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], @@ -1202,7 +1202,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], + "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -1338,6 +1338,8 @@ "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], + "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -1376,7 +1378,7 @@ "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], - "ox": ["ox@0.14.7", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ=="], + "ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], @@ -1408,6 +1410,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="], @@ -1610,7 +1614,7 @@ "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -1624,8 +1628,6 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="], @@ -1668,7 +1670,7 @@ "undici": ["undici@5.28.4", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1716,7 +1718,7 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], - "viem": ["viem@2.47.6", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.7", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q=="], + "viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], @@ -1782,8 +1784,6 @@ "@base-org/account/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], - "@base-org/account/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - "@base-org/account/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], "@coinbase/wallet-sdk/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], @@ -1794,8 +1794,6 @@ "@coinbase/wallet-sdk/ox": ["ox@0.6.9", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug=="], - "@coinbase/wallet-sdk/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - "@coinbase/wallet-sdk/zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], "@ethersproject/providers/ws": ["ws@7.4.6", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="], @@ -1834,24 +1832,12 @@ "@reown/appkit/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], - "@reown/appkit/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - - "@reown/appkit-common/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - "@reown/appkit-controllers/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], - "@reown/appkit-controllers/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - "@reown/appkit-utils/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], - "@reown/appkit-utils/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - "@reown/appkit-wallet/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], - "@safe-global/safe-apps-sdk/viem": ["viem@2.46.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.1", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-c5YPQR/VueqoPG09Tp1JBw2iItKVRGVI0YkWekquRDZw0ciNBhO3muu2QjO9xFelOXh18q3d/kLbW83B2Oxf0g=="], - - "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -2020,16 +2006,8 @@ "@base-org/account/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@base-org/account/viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@base-org/account/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - "@coinbase/wallet-sdk/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@coinbase/wallet-sdk/viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@coinbase/wallet-sdk/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors": ["@metamask/rpc-errors@6.4.0", "", { "dependencies": { "@metamask/utils": "^9.0.0", "fast-safe-stringify": "^2.0.6" } }, "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils": ["@metamask/utils@8.5.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.0.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ=="], @@ -2056,54 +2034,22 @@ "@metamask/sdk/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], - "@reown/appkit-common/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], - "@reown/appkit-controllers/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], - "@reown/appkit-utils/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - "@reown/appkit/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], - "@reown/appkit/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - - "@safe-global/safe-apps-sdk/viem/ox": ["ox@0.12.1", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-uU0llpthaaw4UJoXlseCyBHmQ3bLrQmz9rRLIAUHqv46uHuae9SE+ukYBRIPVCnlEnHKuWjDUcDFHWx9gbGNoA=="], - - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -2170,8 +2116,6 @@ "@metamask/providers/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@reown/appkit-common/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2182,8 +2126,6 @@ "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], - "@reown/appkit-controllers/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2194,8 +2136,6 @@ "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], - "@reown/appkit-utils/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@reown/appkit/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2206,10 +2146,6 @@ "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem": ["viem@2.23.2", "", { "dependencies": { "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.7", "ws": "8.18.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA=="], - "@reown/appkit/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - - "@safe-global/safe-apps-sdk/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], diff --git a/package.json b/package.json index 3fd12fd7d..b25b60dd2 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,12 @@ "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", - "@tanstack/react-query": "5.95.2", + "@tanstack/react-query": "5.90.21", "@tanstack/react-virtual": "3.13.18", "cross-fetch": "4.1.0", "ethers": "5.7.2", - "framer-motion": "12.38.0", - "graphql": "16.13.2", + "framer-motion": "12.34.0", + "graphql": "16.12.0", "react": "19.2.4", "react-dom": "19.2.4", "react-hot-toast": "2.6.0", @@ -39,16 +39,16 @@ "recharts": "2.15.4", "use-indexeddb": "2.0.2", "vaul": "1.1.2", - "viem": "2.47.6", + "viem": "2.46.1", "wagmi": "2.18.2", "zod": "4.3.6" }, "devDependencies": { "@biomejs/biome": "2.4.0", - "@tailwindcss/postcss": "4.2.2", + "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", - "@types/node": "20.19.10", + "@types/node": "25.3.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vercel/node": "5.6.3", @@ -56,9 +56,9 @@ "bump": "0.2.5", "concurrently": "9.2.1", "husky": "9.1.7", - "lint-staged": "16.4.0", + "lint-staged": "16.2.7", "postcss": "8.5.6", - "tailwindcss": "4.2.2", + "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", "vite-plugin-webfont-dl": "3.12.0", From 49abcfec3b94029e1bbe3bbaab0316b86a0de55d Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:46:43 +0200 Subject: [PATCH 07/15] feat: partial dedupe in deposit/withdraw (#1121) --- .../components/widget/deposit/index.tsx | 124 ++++------- .../widget/shared/PriceImpactWarning.tsx | 49 +++++ .../widget/shared/WidgetLoadingSkeleton.tsx | 24 +++ .../widget/shared/usePriceImpactAcceptance.ts | 30 +++ .../widget/shared/useWidgetContext.ts | 44 ++++ .../components/widget/withdraw/index.tsx | 202 +++++++----------- 6 files changed, 272 insertions(+), 201 deletions(-) create mode 100644 src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx create mode 100644 src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx create mode 100644 src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts create mode 100644 src/components/pages/vaults/components/widget/shared/useWidgetContext.ts diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index a8236951c..11d484e4a 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -1,12 +1,7 @@ -import { usePlausible } from '@hooks/usePlausible' import { InputTokenAmount } from '@pages/vaults/components/widget/InputTokenAmount' import { useDebouncedInput } from '@pages/vaults/hooks/useDebouncedInput' -import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { Button } from '@shared/components/Button' -import { useWallet } from '@shared/contexts/useWallet' -import { useWeb3 } from '@shared/contexts/useWeb3' -import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' import { IconSettings } from '@shared/icons/IconSettings' @@ -17,14 +12,16 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatUnits } from 'viem' -import { useAccount } from 'wagmi' import { SettingsPanel } from '../SettingsPanel' +import { PriceImpactWarning } from '../shared/PriceImpactWarning' import { TokenSelectorOverlay } from '../shared/TokenSelectorOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { usePriceImpactAcceptance } from '../shared/usePriceImpactAcceptance' import { useResetEnsoSelection } from '../shared/useResetEnsoSelection' +import { useWidgetContext } from '../shared/useWidgetContext' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' -import { DEPOSIT_COMMON_TOKENS_BY_CHAIN } from '../withdraw/constants' +import { WidgetLoadingSkeleton } from '../shared/WidgetLoadingSkeleton' import { AnnualReturnOverlay } from './AnnualReturnOverlay' import { ApprovalOverlay } from './ApprovalOverlay' import { getDepositApprovalSpender } from './approvalSpender' @@ -149,23 +146,24 @@ export function WidgetDeposit({ deferSuccessEffectsUntilClose = false, deferSuccessEffectsUntilConfettiEnd = true }: Props): ReactElement { - const { address: account } = useAccount() - const { openLoginModal } = useWeb3() - const { onRefresh: refreshWalletBalances, getToken } = useWallet() - const { zapSlippage, isAutoStakingEnabled, getPrice, allVaults } = useYearn() - const trackEvent = usePlausible() - const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + const { + account, + openLoginModal, + refreshWalletBalances, + getToken, + zapSlippage, + isAutoStakingEnabled, + getPrice, + trackEvent, + ensoEnabled + } = useWidgetContext({ chainId, vaultAddress }) - const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(prefill?.address ?? assetAddress) - const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) const [showVaultSharesModal, setShowVaultSharesModal] = useState(false) const [showVaultShareValueModal, setShowVaultShareValueModal] = useState(false) const [showAnnualReturnModal, setShowAnnualReturnModal] = useState(false) const [showApprovalOverlay, setShowApprovalOverlay] = useState(false) - const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const appliedPrefillRef = useRef(null) const { assetToken, @@ -175,6 +173,15 @@ export function WidgetDeposit({ isLoading: isLoadingVaultData, refetch: refetchVaultUserData } = vaultUserData + + // ============================================================================ + // Token Selection & Input + // ============================================================================ + const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(prefill?.address ?? assetAddress) + const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) + const [showTokenSelector, setShowTokenSelector] = useState(false) + const appliedPrefillRef = useRef(null) + // Derived token values const depositToken = selectedToken || assetAddress const sourceChainId = selectedChainId || chainId @@ -225,9 +232,6 @@ export function WidgetDeposit({ return vaultAddress }, [isAutoStakingEnabled, stakingAddress, vaultAddress]) - // ============================================================================ - // Input Handling - // ============================================================================ const depositInput = useDebouncedInput(inputToken?.decimals ?? 18) const [depositAmount, , setDepositInput] = depositInput const shouldCollapseDetails = Boolean(collapseDetails && !hideDetails && !hideActionButton) @@ -391,41 +395,16 @@ export function WidgetDeposit({ } }, [depositValueInfo.priceImpactPercentage, depositValueInfo.isHighPriceImpact]) - const priceImpactAcceptanceKey = useMemo(() => { - return [ - depositAmount.bn.toString(), - routeType, - sourceChainId, - depositToken, - destinationToken, - activeFlow.periphery.routerAddress ?? '', - activeFlow.periphery.expectedOut.toString() - ].join(':') - }, [ + const { hasAcceptedPriceImpact, priceImpactAcceptanceKey, setAcceptedPriceImpactKey } = usePriceImpactAcceptance([ depositAmount.bn, routeType, sourceChainId, depositToken, destinationToken, - activeFlow.periphery.routerAddress, + activeFlow.periphery.routerAddress ?? '', activeFlow.periphery.expectedOut ]) - const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{ - key: string - isAccepted: boolean - }>({ - key: priceImpactAcceptanceKey, - isAccepted: false - }) - if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) { - setPriceImpactAcceptanceState({ - key: priceImpactAcceptanceKey, - isAccepted: false - }) - } - const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted - const formattedDepositAmount = formatTAmount({ value: depositAmount.bn, decimals: inputToken?.decimals ?? 18 }) const needsApproval = !isNativeToken && !activeFlow.periphery.isAllowanceSufficient @@ -554,14 +533,7 @@ export function WidgetDeposit({ ) if (isLoadingVaultData) { - return ( -
- -
-
-
-
- ) + return } // ============================================================================ @@ -622,34 +594,23 @@ export function WidgetDeposit({ /> ) - const priceImpactWarning = priceImpactInfo.isHigh && - !isLoadingQuote && - !depositAmount.isDebouncing && - depositAmount.bn === depositAmount.debouncedBn && - depositAmount.bn > 0n && ( -
-

- Price impact is high ({priceImpactInfo.percentage.toFixed(2)}%). Consider depositing less or waiting for - better liquidity conditions. -

- -
- ) + const priceImpactWarning = ( + 0n} + hasAcceptedPriceImpact={hasAcceptedPriceImpact} + priceImpactAcceptanceKey={priceImpactAcceptanceKey} + setAcceptedPriceImpactKey={setAcceptedPriceImpactKey} + actionVerb="depositing" + /> + ) const showActionRow = !hideActionButton || !!onOpenSettings + const showSettingsButton = !!account && !!onOpenSettings const depositButtonLabel = getDepositButtonLabel(isLoadingQuote, needsApproval, routeType) const isDepositButtonDisabled = !!depositError || @@ -659,7 +620,6 @@ export function WidgetDeposit({ (!activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareApproveEnabled) || (activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareDepositEnabled) || (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) - const showSettingsButton = !!account && !!onOpenSettings const actionRow = showActionRow ? (
diff --git a/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx b/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx new file mode 100644 index 000000000..7ea6aa326 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/PriceImpactWarning.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from 'react' + +type TPriceImpactWarningProps = { + percentage: number + isHigh: boolean + isLoading: boolean + isDebouncing: boolean + isAmountSynced: boolean + hasAmount: boolean + hasAcceptedPriceImpact: boolean + priceImpactAcceptanceKey: string + setAcceptedPriceImpactKey: (key: string | null) => void + actionVerb?: string +} + +export function PriceImpactWarning({ + percentage, + isHigh, + isLoading, + isDebouncing, + isAmountSynced, + hasAmount, + hasAcceptedPriceImpact, + priceImpactAcceptanceKey, + setAcceptedPriceImpactKey, + actionVerb = 'depositing' +}: TPriceImpactWarningProps): ReactElement | null { + if (!isHigh || isLoading || isDebouncing || !isAmountSynced || !hasAmount) { + return null + } + + return ( +
+

+ Price impact is high ({percentage.toFixed(2)}%). Consider {actionVerb} less or waiting for better liquidity + conditions. +

+ +
+ ) +} diff --git a/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx b/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx new file mode 100644 index 000000000..b85a75d07 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/WidgetLoadingSkeleton.tsx @@ -0,0 +1,24 @@ +import { WidgetHeader } from '@pages/vaults/components/widget/shared/WidgetHeader' +import { cl } from '@shared/utils' +import type { ReactElement, ReactNode } from 'react' + +type TWidgetLoadingSkeletonProps = { + title: string + actions?: ReactNode + disableBorderRadius?: boolean +} + +export function WidgetLoadingSkeleton({ + title, + actions, + disableBorderRadius +}: TWidgetLoadingSkeletonProps): ReactElement { + return ( +
+ +
+
+
+
+ ) +} diff --git a/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts b/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts new file mode 100644 index 000000000..5098d1081 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/usePriceImpactAcceptance.ts @@ -0,0 +1,30 @@ +import { useMemo, useState } from 'react' + +type TPriceImpactAcceptance = { + hasAcceptedPriceImpact: boolean + setAcceptedPriceImpactKey: (key: string | null) => void + priceImpactAcceptanceKey: string +} + +export function usePriceImpactAcceptance( + routeKeyParts: (string | number | bigint | undefined | null)[] +): TPriceImpactAcceptance { + const [acceptedPriceImpactKey, setAcceptedPriceImpactKey] = useState(null) + + // The array parameter IS the useMemo deps — React iterates its elements and + // compares each with Object.is. This works because callers pass inline array + // literals whose elements are primitive (string | number | bigint). + const priceImpactAcceptanceKey = useMemo( + () => routeKeyParts.map((part) => String(part ?? '')).join(':'), + // eslint-disable-next-line react-hooks/exhaustive-deps + routeKeyParts + ) + + const hasAcceptedPriceImpact = acceptedPriceImpactKey === priceImpactAcceptanceKey + + return { + hasAcceptedPriceImpact, + setAcceptedPriceImpactKey, + priceImpactAcceptanceKey + } +} diff --git a/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts b/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts new file mode 100644 index 000000000..d73231c69 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/useWidgetContext.ts @@ -0,0 +1,44 @@ +import { usePlausible } from '@hooks/usePlausible' +import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' +import { useWallet } from '@shared/contexts/useWallet' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { useAccount } from 'wagmi' + +type TUseWidgetContextParams = { + chainId: number + vaultAddress: `0x${string}` +} + +type TWidgetContext = { + account: `0x${string}` | undefined + openLoginModal: (() => void) | undefined + refreshWalletBalances: ReturnType['onRefresh'] + getToken: ReturnType['getToken'] + zapSlippage: number + isAutoStakingEnabled: boolean + getPrice: ReturnType['getPrice'] + trackEvent: ReturnType + ensoEnabled: boolean +} + +export function useWidgetContext({ chainId, vaultAddress }: TUseWidgetContextParams): TWidgetContext { + const { address: account } = useAccount() + const { openLoginModal } = useWeb3() + const { onRefresh: refreshWalletBalances, getToken } = useWallet() + const { zapSlippage, isAutoStakingEnabled, getPrice } = useYearn() + const trackEvent = usePlausible() + const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + + return { + account, + openLoginModal, + refreshWalletBalances, + getToken, + zapSlippage, + isAutoStakingEnabled, + getPrice, + trackEvent, + ensoEnabled + } +} diff --git a/src/components/pages/vaults/components/widget/withdraw/index.tsx b/src/components/pages/vaults/components/widget/withdraw/index.tsx index f1d43f899..742cad3aa 100644 --- a/src/components/pages/vaults/components/widget/withdraw/index.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/index.tsx @@ -1,10 +1,5 @@ -import { usePlausible } from '@hooks/usePlausible' import { useDebouncedInput } from '@pages/vaults/hooks/useDebouncedInput' -import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' import { Button } from '@shared/components/Button' -import { useWallet } from '@shared/contexts/useWallet' -import { useWeb3 } from '@shared/contexts/useWeb3' -import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' import { IconSettings } from '@shared/icons/IconSettings' @@ -13,15 +8,18 @@ import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { ReactElement, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatUnits } from 'viem' -import { useAccount } from 'wagmi' import { ApprovalOverlay } from '../deposit/ApprovalOverlay' import { InputTokenAmount } from '../InputTokenAmount' import { SettingsPanel } from '../SettingsPanel' +import { PriceImpactWarning } from '../shared/PriceImpactWarning' import { TokenSelectorOverlay } from '../shared/TokenSelectorOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { usePriceImpactAcceptance } from '../shared/usePriceImpactAcceptance' import { useResetEnsoSelection } from '../shared/useResetEnsoSelection' +import { useWidgetContext } from '../shared/useWidgetContext' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' +import { WidgetLoadingSkeleton } from '../shared/WidgetLoadingSkeleton' import { getPriorityTokens } from './constants' import { SourceSelector } from './SourceSelector' import type { WithdrawalSource, WithdrawWidgetProps } from './types' @@ -124,25 +122,16 @@ export function WidgetWithdraw({ contentBelowInput, hideContainerBorder = false }: WidgetWithdrawProps): ReactElement { - const { address: account } = useAccount() - const { openLoginModal } = useWeb3() - const { onRefresh: refreshWalletBalances, getToken } = useWallet() - const { zapSlippage, getPrice } = useYearn() - const trackEvent = usePlausible() - const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) + const { account, openLoginModal, refreshWalletBalances, getToken, zapSlippage, getPrice, trackEvent, ensoEnabled } = + useWidgetContext({ chainId, vaultAddress }) + const resolvedDisplayAssetAddress = displayAssetAddress ?? assetAddress - const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>( - prefill?.address ?? resolvedDisplayAssetAddress - ) - const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) const [showWithdrawDetailsModal, setShowWithdrawDetailsModal] = useState(false) const [showApprovalOverlay, setShowApprovalOverlay] = useState(false) - const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [withdrawalSource, setWithdrawalSource] = useState(stakingAddress ? null : 'vault') const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) - const appliedPrefillRef = useRef(null) const [fallbackStep, setFallbackStep] = useState<'unstake' | 'withdraw'>('unstake') const [redeemSharesOverride, setRedeemSharesOverride] = useState(0n) const [awaitingPostUnstakeShares, setAwaitingPostUnstakeShares] = useState(false) @@ -162,6 +151,41 @@ export function WidgetWithdraw({ const priorityTokens = getPriorityTokens(chainId, vaultAddress, stakingAddress) + const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>( + prefill?.address ?? resolvedDisplayAssetAddress + ) + const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) + const [showTokenSelector, setShowTokenSelector] = useState(false) + const appliedPrefillRef = useRef(null) + + const withdrawInput = useDebouncedInput(assetToken?.decimals ?? 18) + const [withdrawAmount, , setWithdrawInput] = withdrawInput + + useResetEnsoSelection({ + ensoEnabled, + selectedToken, + selectedChainId, + assetAddress: resolvedDisplayAssetAddress, + chainId, + showTokenSelector, + setSelectedToken, + setSelectedChainId, + setShowTokenSelector + }) + + useEffect(() => { + if (!prefill) return + const key = `${prefillRequestKey ?? ''}-${prefill.address}-${prefill.chainId}-${prefill.amount}` + if (appliedPrefillRef.current === key) return + appliedPrefillRef.current = key + setSelectedToken(prefill.address) + setSelectedChainId(prefill.chainId) + if (prefill.amount !== undefined) { + setWithdrawInput(prefill.amount) + } + onPrefillApplied?.() + }, [prefill, prefillRequestKey, setWithdrawInput, onPrefillApplied]) + // Derived token values const withdrawToken = selectedToken || resolvedDisplayAssetAddress const destinationChainId = selectedChainId || chainId @@ -245,22 +269,6 @@ export function WidgetWithdraw({ return toNormalizedBN(underlyingAmount, assetToken.decimals ?? 18) }, [sourceVaultSharesRaw, pricePerShare, vaultDecimals, assetToken]) - const withdrawInput = useDebouncedInput(assetToken?.decimals ?? 18) - const [withdrawAmount, , setWithdrawInput] = withdrawInput - - useEffect(() => { - if (!prefill) return - const key = `${prefillRequestKey ?? ''}-${prefill.address}-${prefill.chainId}-${prefill.amount}` - if (appliedPrefillRef.current === key) return - appliedPrefillRef.current = key - setSelectedToken(prefill.address) - setSelectedChainId(prefill.chainId) - if (prefill.amount !== undefined) { - setWithdrawInput(prefill.amount) - } - onPrefillApplied?.() - }, [prefill, prefillRequestKey, setWithdrawInput, onPrefillApplied]) - useEffect(() => { onAmountChange?.(withdrawAmount.bn) }, [withdrawAmount.bn, onAmountChange]) @@ -484,45 +492,18 @@ export function WidgetWithdraw({ outputTokenPrice ]) - const priceImpactAcceptanceKey = useMemo(() => { - return [ - withdrawAmount.bn.toString(), - effectiveRequiredShares.toString(), - routeType, - withdrawalSource ?? '', - sourceToken, - withdrawToken, - destinationChainId, - activeFlow.periphery.routerAddress ?? '', - effectiveExpectedOut.toString() - ].join(':') - }, [ + const { hasAcceptedPriceImpact, priceImpactAcceptanceKey, setAcceptedPriceImpactKey } = usePriceImpactAcceptance([ withdrawAmount.bn, effectiveRequiredShares, routeType, - withdrawalSource, + withdrawalSource ?? '', sourceToken, withdrawToken, destinationChainId, - activeFlow.periphery.routerAddress, + activeFlow.periphery.routerAddress ?? '', effectiveExpectedOut ]) - const [priceImpactAcceptanceState, setPriceImpactAcceptanceState] = useState<{ - key: string - isAccepted: boolean - }>({ - key: priceImpactAcceptanceKey, - isAccepted: false - }) - if (priceImpactAcceptanceState.key !== priceImpactAcceptanceKey) { - setPriceImpactAcceptanceState({ - key: priceImpactAcceptanceKey, - isAccepted: false - }) - } - const hasAcceptedPriceImpact = priceImpactAcceptanceState.isAccepted - const canOpenTokenSelector = ensoEnabled && !disableTokenSelector const shouldShowZapUi = !isBaseWithdrawToken const canShowAssetTokenSelector = canOpenTokenSelector && !shouldShowZapUi @@ -707,14 +688,7 @@ export function WidgetWithdraw({ ]) if (isLoadingVaultData) { - return ( -
- -
-
-
-
- ) + return } // ============================================================================ @@ -729,7 +703,6 @@ export function WidgetWithdraw({ setWithdrawInput(formatUnits(underlyingAmount, assetToken?.decimals ?? 18)) } : undefined - const showSettingsButton = !!account && !!onOpenSettings const zapNotificationText = getZapNotificationText(isUnstake, shouldShowZapUi) const onRemoveZap = canOpenTokenSelector ? (): void => { @@ -768,32 +741,40 @@ export function WidgetWithdraw({ /> ) - const priceImpactWarning = priceImpactInfo.isHigh && - !isFetchingQuote && - !withdrawAmount.isDebouncing && - withdrawAmount.bn === withdrawAmount.debouncedBn && - withdrawAmount.bn > 0n && ( -
-

- Price impact is high ({priceImpactInfo.percentage.toFixed(2)}%). Consider withdrawing less or waiting for - better liquidity conditions. -

- -
- ) + const priceImpactWarning = ( + 0n} + hasAcceptedPriceImpact={hasAcceptedPriceImpact} + priceImpactAcceptanceKey={priceImpactAcceptanceKey} + setAcceptedPriceImpactKey={setAcceptedPriceImpactKey} + actionVerb="withdrawing" + /> + ) + + const showSettingsButton = !!account && !!onOpenSettings + const withdrawButtonLabel = getWithdrawCtaLabel({ + isFetchingQuote, + showApprove: approvalState.hasApprovalStep, + isAllowanceSufficient: approvalState.isAllowanceSufficient, + transactionName + }) + const isWithdrawButtonDisabled = + isWithdrawCtaDisabled({ + hasError: !!effectiveWithdrawError || isActionDisabled, + withdrawAmountRaw: withdrawAmount.bn, + isFetchingQuote, + isDebouncing: withdrawAmount.isDebouncing, + showApprove: approvalState.hasApprovalStep, + isAllowanceSufficient: approvalState.isAllowanceSufficient, + prepareApproveEnabled: Boolean(activeFlow.periphery.prepareApproveEnabled), + prepareWithdrawEnabled: Boolean(activeFlow.periphery.prepareWithdrawEnabled) + }) || + (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) const actionRow = (
@@ -814,28 +795,11 @@ export function WidgetWithdraw({ onClick={handleOpenTransactionOverlay} variant={isFetchingQuote ? 'busy' : 'filled'} isBusy={isFetchingQuote} - disabled={ - isWithdrawCtaDisabled({ - hasError: !!effectiveWithdrawError || isActionDisabled, - withdrawAmountRaw: withdrawAmount.bn, - isFetchingQuote, - isDebouncing: withdrawAmount.isDebouncing, - showApprove: approvalState.hasApprovalStep, - isAllowanceSufficient: approvalState.isAllowanceSufficient, - prepareApproveEnabled: Boolean(activeFlow.periphery.prepareApproveEnabled), - prepareWithdrawEnabled: Boolean(activeFlow.periphery.prepareWithdrawEnabled) - }) || - (priceImpactInfo.isHigh && !hasAcceptedPriceImpact) - } + disabled={isWithdrawButtonDisabled} className="w-full" classNameOverride="yearn--button--nextgen w-full" > - {getWithdrawCtaLabel({ - isFetchingQuote, - showApprove: approvalState.hasApprovalStep, - isAllowanceSufficient: approvalState.isAllowanceSufficient, - transactionName - })} + {withdrawButtonLabel} )}
@@ -1006,9 +970,9 @@ export function WidgetWithdraw({ isOpen={showTokenSelector} onClose={() => setShowTokenSelector(false)} onChange={(address, chainIdValue) => { + setWithdrawInput('') setSelectedToken(address) setSelectedChainId(chainIdValue) - setWithdrawInput('') setShowTokenSelector(false) activeFlow.periphery.resetQuote?.() }} From 7873cece63bb7edf19e4d2f66b290fb106dd291a Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:47:39 -0400 Subject: [PATCH 08/15] chore: fix font fallbacks to prevent horrible font rendering on some computers (#1141) --- bun.lock | 46 -- package.json | 1 - public/fonts/fonts.css | 36 +- src/components/pages/font-fallbacks/index.tsx | 529 ++++++++++++++++++ src/routes.tsx | 4 + style.css | 18 +- vite.config.ts | 13 +- 7 files changed, 570 insertions(+), 77 deletions(-) create mode 100644 src/components/pages/font-fallbacks/index.tsx diff --git a/bun.lock b/bun.lock index 535717e23..b605725eb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "yearnfi", @@ -46,7 +45,6 @@ "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", - "vite-plugin-webfont-dl": "3.12.0", "vitest": "1.6.1", }, }, @@ -118,10 +116,6 @@ "@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="], - "@cacheable/memory": ["@cacheable/memory@2.0.7", "", { "dependencies": { "@cacheable/utils": "^2.3.3", "@keyv/bigmap": "^1.3.0", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A=="], - - "@cacheable/utils": ["@cacheable/utils@2.3.4", "", { "dependencies": { "hashery": "^1.3.0", "keyv": "^5.6.0" } }, "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA=="], - "@coinbase/wallet-sdk": ["@coinbase/wallet-sdk@4.3.6", "", { "dependencies": { "@noble/hashes": "1.4.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", "viem": "^2.27.2", "zustand": "5.0.3" } }, "sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], @@ -292,10 +286,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], - - "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], - "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], @@ -712,14 +702,10 @@ "async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -758,8 +744,6 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacheable": ["cacheable@2.3.2", "", { "dependencies": { "@cacheable/memory": "^2.0.7", "@cacheable/utils": "^2.3.3", "hookified": "^1.15.0", "keyv": "^5.5.5", "qified": "^0.6.0" } }, "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA=="], - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -794,8 +778,6 @@ "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], - "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], @@ -812,8 +794,6 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -900,8 +880,6 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "derive-valtio": ["derive-valtio@0.1.0", "", { "peerDependencies": { "valtio": "*" } }, "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A=="], @@ -958,8 +936,6 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-toolkit": ["es-toolkit@1.33.0", "", {}, "sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg=="], "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], @@ -1018,16 +994,8 @@ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "flat-cache": ["flat-cache@6.1.20", "", { "dependencies": { "cacheable": "^2.3.2", "flatted": "^3.3.3", "hookified": "^1.15.0" } }, "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ=="], - - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], "fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], @@ -1080,8 +1048,6 @@ "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], - "hashery": ["hashery@1.5.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], @@ -1092,8 +1058,6 @@ "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], - "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1174,8 +1138,6 @@ "keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="], - "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "keyvaluestorage-interface": ["keyvaluestorage-interface@1.0.0", "", {}, "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -1450,14 +1412,10 @@ "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qified": ["qified@0.6.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA=="], - "qr": ["qr@0.5.4", "", {}, "sha512-gjVMHOt7CX+BQd7JLQ9fnS4kJK4Lj4u+Conq52tcCbW7YH3mATTtBbTMA+7cQ1rKOkDo61olFHJReawe+XFxIA=="], "qrcode": ["qrcode@1.5.3", "", { "dependencies": { "dijkstrajs": "^1.0.1", "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg=="], @@ -1572,8 +1530,6 @@ "sonic-boom": ["sonic-boom@2.8.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -1724,8 +1680,6 @@ "vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="], - "vite-plugin-webfont-dl": ["vite-plugin-webfont-dl@3.12.0", "", { "dependencies": { "axios": "^1", "clean-css": "^5", "flat-cache": "^6", "picocolors": "^1" }, "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "sha512-0jxsr8ycuoK/uV5Y3ytttTRhgvfZo8v3O4JZBlVc4C7QWIws/vCLVR4B3ag+TGVkLNQya6hXfY3UnZge3M8vmA=="], - "vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="], "wagmi": ["wagmi@2.18.2", "", { "dependencies": { "@wagmi/connectors": "6.1.0", "@wagmi/core": "2.22.1", "use-sync-external-store": "1.4.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "typescript": ">=5.0.4", "viem": "2.x" }, "optionalPeers": ["typescript"] }, "sha512-9jFip+0ZfjMBxT72m02MZD2+VmQQ/UmqZhHl+98N9HEqXLn765fIu45QPV85DAnQqIHD81gvY3vTvfWt16A5yQ=="], diff --git a/package.json b/package.json index b25b60dd2..82cbd4fb2 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.1", - "vite-plugin-webfont-dl": "3.12.0", "vitest": "1.6.1" } } diff --git a/public/fonts/fonts.css b/public/fonts/fonts.css index 4deba7937..7e379dcf8 100755 --- a/public/fonts/fonts.css +++ b/public/fonts/fonts.css @@ -1,7 +1,10 @@ /* Aeonik Font Family */ @font-face { font-family: "Aeonik"; - src: url("./Aeonik-Light.ttf") format("truetype"); + src: + local("Aeonik Light"), + local("Aeonik-Light"), + url("./Aeonik-Light.ttf") format("truetype"); font-weight: 300; font-style: normal; font-display: swap; @@ -10,6 +13,9 @@ @font-face { font-family: "Aeonik"; src: + local("Aeonik"), + local("Aeonik Regular"), + local("Aeonik-Regular"), url("./Aeonik-Regular.woff2") format("woff2"), url("./Aeonik-Regular.woff") format("woff"), url("./Aeonik-Regular.ttf") format("truetype"); @@ -21,6 +27,8 @@ @font-face { font-family: "Aeonik"; src: + local("Aeonik Bold"), + local("Aeonik-Bold"), url("./Aeonik-Bold.woff2") format("woff2"), url("./Aeonik-Bold.woff") format("woff"), url("./Aeonik-Bold.ttf") format("truetype"); @@ -31,12 +39,24 @@ @font-face { font-family: "Aeonik"; - src: url("./Aeonik-Black.ttf") format("truetype"); + src: + local("Aeonik Black"), + local("Aeonik-Black"), + url("./Aeonik-Black.ttf") format("truetype"); font-weight: 900; font-style: normal; font-display: swap; } +/* Explicit sans fallback used when Aeonik cannot be resolved */ +@font-face { + font-family: "Aeonik Fallback"; + src: local("SF Pro Text"), local("SF Pro Display"), local("Arial"), local("Segoe UI"), local("Roboto"); + font-weight: 300 900; + font-style: normal; + font-display: swap; +} + /* Aeonik Fono Font Family */ @font-face { font-family: "Aeonik Fono"; @@ -58,6 +78,9 @@ @font-face { font-family: "Aeonik Mono"; src: + local("Aeonik Mono"), + local("AeonikMono Regular"), + local("AeonikMono-Regular"), url("./AeonikMono-Regular.woff2") format("woff2"), url("./AeonikMono-Regular.woff") format("woff"), url("./AeonikMono-Regular.ttf") format("truetype"); @@ -69,6 +92,9 @@ @font-face { font-family: "Aeonik Mono"; src: + local("Aeonik Mono Bold"), + local("AeonikMono Bold"), + local("AeonikMono-Bold"), url("./AeonikMono-Bold.woff2") format("woff2"), url("./AeonikMono-Bold.woff") format("woff"), url("./AeonikMono-Bold.ttf") format("truetype"); @@ -79,8 +105,6 @@ /* CSS Variables for Fonts */ :root { - --font-aeonik: "Aeonik", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-aeonik-fono: - "Aeonik Fono", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --scp-font: "Source Code Pro", "Aeonik Mono", Consolas, "Courier New", monospace; + --font-aeonik: "Aeonik", Helvetica, Arial, system-ui, sans-serif; + --font-aeonik-fono: "Aeonik Fono", Helvetica, Arial, system-ui, sans-serif; } diff --git a/src/components/pages/font-fallbacks/index.tsx b/src/components/pages/font-fallbacks/index.tsx new file mode 100644 index 000000000..78f366a0c --- /dev/null +++ b/src/components/pages/font-fallbacks/index.tsx @@ -0,0 +1,529 @@ +import { Meta } from '@shared/components/Meta' +import type { ReactElement, ReactNode } from 'react' +import { useEffect, useState } from 'react' + +type TFontCandidate = { + family: string + kind: 'named' | 'system' | 'generic' + label: string + note: string +} + +type TFontIdentityCandidate = Pick +type TFontProbe = { + fontSizePx: number + fontStyle: 'normal' | 'italic' + fontWeight: number + label: string + sampleText: string +} + +type TRenderedFontMatch = { + aliases: string[] + label: string + primaryLabel: string | null +} + +const SANS_STACK: TFontCandidate[] = [ + { family: '"Aeonik"', kind: 'named', label: 'Aeonik', note: 'Primary self-hosted UI sans.' }, + { family: 'Helvetica', kind: 'named', label: 'Helvetica', note: 'Primary Apple-compatible sans fallback.' }, + { family: 'Arial', kind: 'named', label: 'Arial', note: 'Cross-platform safety-net sans.' }, + { + family: 'system-ui', + kind: 'system', + label: 'system-ui', + note: 'Late cross-platform system UI fallback keyword.' + }, + { family: 'sans-serif', kind: 'generic', label: 'sans-serif', note: 'Browser generic sans fallback.' } +] + +const MONO_STACK: TFontCandidate[] = [ + { family: '"Aeonik Mono"', kind: 'named', label: 'Aeonik Mono', note: 'Primary mono for numeric UI samples.' }, + { family: 'Menlo', kind: 'named', label: 'Menlo', note: 'Preferred macOS mono fallback.' }, + { family: 'Consolas', kind: 'named', label: 'Consolas', note: 'Preferred Windows mono fallback.' }, + { family: '"Liberation Mono"', kind: 'named', label: 'Liberation Mono', note: 'Common Linux mono fallback.' }, + { family: 'monospace', kind: 'generic', label: 'monospace', note: 'Browser generic mono fallback.' } +] + +const SANS_RENDER_CANDIDATES: TFontIdentityCandidate[] = [ + { family: '"Aeonik"', kind: 'named', label: 'Aeonik' }, + { family: '"SF Pro Text"', kind: 'named', label: 'SF Pro Text' }, + { family: '"SF Pro Display"', kind: 'named', label: 'SF Pro Display' }, + { family: 'Helvetica', kind: 'named', label: 'Helvetica' }, + { family: 'Arial', kind: 'named', label: 'Arial' }, + { family: '"Segoe UI"', kind: 'named', label: 'Segoe UI' }, + { family: 'Roboto', kind: 'named', label: 'Roboto' }, + { family: 'system-ui', kind: 'system', label: 'system-ui' }, + { family: 'sans-serif', kind: 'generic', label: 'sans-serif' } +] + +const MONO_RENDER_CANDIDATES: TFontIdentityCandidate[] = [ + { family: '"Aeonik Mono"', kind: 'named', label: 'Aeonik Mono' }, + { family: '"SFMono-Regular"', kind: 'named', label: 'SFMono-Regular' }, + { family: 'Menlo', kind: 'named', label: 'Menlo' }, + { family: 'Consolas', kind: 'named', label: 'Consolas' }, + { family: '"Liberation Mono"', kind: 'named', label: 'Liberation Mono' }, + { family: 'monospace', kind: 'generic', label: 'monospace' } +] + +const SANS_RENDER_PROBES: TFontProbe[] = [ + { label: 'Regular', fontSizePx: 14, fontStyle: 'normal', fontWeight: 400, sampleText: 'Yearn BOLD Stability Pool' }, + { label: 'Bold', fontSizePx: 18, fontStyle: 'normal', fontWeight: 900, sampleText: 'Yearn BOLD 2.74%' }, + { label: 'Italic', fontSizePx: 14, fontStyle: 'italic', fontWeight: 400, sampleText: 'Italic stress sample' } +] + +const MONO_RENDER_PROBES: TFontProbe[] = [ + { label: 'Regular', fontSizePx: 16, fontStyle: 'normal', fontWeight: 400, sampleText: '2.74% 3.00% $4.54M' }, + { label: 'Bold', fontSizePx: 14, fontStyle: 'normal', fontWeight: 700, sampleText: '9,430 BOLD ($9,480)' }, + { + label: 'Italic', + fontSizePx: 12, + fontStyle: 'italic', + fontWeight: 400, + sampleText: 'Projected settlement 03/26/26 14:08' + } +] + +const BODY_STACK = SANS_STACK.map((entry) => entry.family).join(', ') +const NUMBER_STACK = MONO_STACK.map((entry) => entry.family).join(', ') + +const DETAIL_STATS = [ + { label: 'Est. APY', value: '2.74%' }, + { label: 'TVL', value: '$4.54M' }, + { label: 'Your deposits', value: '$0.00' } +] + +const NUMBER_ROWS = [ + { label: 'Vault share value', value: '9,430 BOLD ($9,480)' }, + { label: 'Expected out', value: '10.0K BOLD' }, + { label: 'Price impact', value: '-5.66%' } +] + +function buildFallbackStack(candidates: TFontCandidate[], startIndex: number): string { + return candidates + .slice(startIndex) + .map((entry) => entry.family) + .join(', ') +} + +function buildCanvasFont(probe: TFontProbe, fontFamily: string): string { + return `${probe.fontStyle} ${probe.fontWeight} ${probe.fontSizePx}px ${fontFamily}` +} + +function buildFontFingerprint(fontFamily: string, probe: TFontProbe): string | null { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d', { willReadFrequently: true }) + + if (!context) { + return null + } + + context.font = buildCanvasFont(probe, fontFamily) + context.textBaseline = 'top' + + const width = Math.max(1, Math.ceil(context.measureText(probe.sampleText).width + 32)) + const height = Math.max(1, Math.ceil(probe.fontSizePx * 3)) + + canvas.width = width + canvas.height = height + + const renderContext = canvas.getContext('2d', { willReadFrequently: true }) + + if (!renderContext) { + return null + } + + renderContext.fillStyle = '#ffffff' + renderContext.fillRect(0, 0, width, height) + renderContext.fillStyle = '#000000' + renderContext.font = buildCanvasFont(probe, fontFamily) + renderContext.textBaseline = 'top' + renderContext.fontKerning = 'normal' + renderContext.fillText(probe.sampleText, 16, 12) + + const imageData = renderContext.getImageData(0, 0, width, height).data + const hash = imageData.reduce((currentHash, channel) => Math.imul(currentHash ^ channel, 16777619), 2166136261) + + return `${width}:${height}:${hash >>> 0}` +} + +function rankRenderedMatches(matches: TFontIdentityCandidate[]): TFontIdentityCandidate[] { + const priority = { + named: 0, + system: 1, + generic: 2 + } + + return [...matches].sort((candidateA, candidateB) => priority[candidateA.kind] - priority[candidateB.kind]) +} + +function inferRenderedFontMatch( + stack: string, + candidates: TFontIdentityCandidate[], + probe: TFontProbe +): TRenderedFontMatch { + const stackFingerprint = buildFontFingerprint(stack, probe) + + if (!stackFingerprint) { + return { label: probe.label, primaryLabel: null, aliases: [] } + } + + const matches = candidates.filter((candidate) => buildFontFingerprint(candidate.family, probe) === stackFingerprint) + + if (!matches.length) { + return { label: probe.label, primaryLabel: null, aliases: [] } + } + + const rankedMatches = rankRenderedMatches(matches) + + return { + label: probe.label, + primaryLabel: rankedMatches[0]?.label || null, + aliases: rankedMatches.slice(1).map((candidate) => candidate.label) + } +} + +function inferRenderedFontMatches( + stack: string, + candidates: TFontIdentityCandidate[], + probes: TFontProbe[] +): TRenderedFontMatch[] { + return probes.map((probe) => inferRenderedFontMatch(stack, candidates, probe)) +} + +function formatRenderedFontMatches(matches: TRenderedFontMatch[]): string { + const primaryLabels = [ + ...new Set(matches.map((match) => match.primaryLabel).filter((label): label is string => Boolean(label))) + ] + + if (!primaryLabels.length) { + return 'No exact match in known candidates' + } + + if (primaryLabels.length === 1) { + return primaryLabels[0] + } + + return matches.map((match) => `${match.label} ${match.primaryLabel || 'Unknown'}`).join(' · ') +} + +function RenderedFontHeader({ + isolatedStack, + fallbackStack, + candidates, + probes +}: { + candidates: TFontIdentityCandidate[] + fallbackStack: string + isolatedStack: string + probes: TFontProbe[] +}): ReactElement { + const [isolatedMatches, setIsolatedMatches] = useState([]) + const [fallbackMatches, setFallbackMatches] = useState([]) + + useEffect(() => { + let isActive = true + + const syncMatches = (): void => { + if (!isActive) { + return + } + + setIsolatedMatches(inferRenderedFontMatches(isolatedStack, candidates, probes)) + setFallbackMatches(inferRenderedFontMatches(fallbackStack, candidates, probes)) + } + + syncMatches() + + const fontSet = document.fonts + + void fontSet.ready.then(() => { + syncMatches() + }) + + fontSet.addEventListener('loadingdone', syncMatches) + fontSet.addEventListener('loadingerror', syncMatches) + + return () => { + isActive = false + fontSet.removeEventListener('loadingdone', syncMatches) + fontSet.removeEventListener('loadingerror', syncMatches) + } + }, [candidates, fallbackStack, isolatedStack, probes]) + + return ( +
+

{'Likely Rendered'}

+
+

+ {'Isolated: '} + {formatRenderedFontMatches(isolatedMatches)} +

+

+ {'Fallback: '} + {formatRenderedFontMatches(fallbackMatches)} +

+
+
+ ) +} + +function PreviewShell({ children, fontFamily }: { children: ReactNode; fontFamily: string }): ReactElement { + return ( +
+ {children} +
+ ) +} + +function PreviewPanel({ label, stack, preview }: { label: string; preview: ReactNode; stack: string }): ReactElement { + return ( +
+
+

{label}

+ {stack} +
+ {preview} +
+ ) +} + +function VaultSansPreview({ fontFamily }: { fontFamily: string }): ReactElement { + return ( + +
+

{'Vault Header'}

+

{'Yearn BOLD'}

+

{'Ethereum · Stablecoin · Single Asset'}

+
+ +
+ {DETAIL_STATS.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ +
+

+ { + "yBOLD is Yearn's BOLD tokenized Stability Pool product. It is designed to tokenize the benefits of a BOLD-in-stability-pool position." + } +

+

+ { + 'Italic stress sample: explanatory copy and warning notes should still read cleanly if Aeonik is unavailable.' + } +

+
+ +
+ {'Approve & Deposit'} +
+
+ ) +} + +function VaultMonoPreview({ fontFamily }: { fontFamily: string }): ReactElement { + return ( + +
+

{'Metric Samples'}

+

{'2.74% 3.00% $4.54M'}

+
+ +
+
+ {NUMBER_ROWS.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+ +
+

+ {'0x9f4330700a36b29952869fac9b33f45eedd8a3d8'} +

+

{'Projected settlement 03/26/26 14:08'}

+
+
+ ) +} + +function FontCard({ + candidate, + fallbackStack, + isolatedPreview, + fallbackPreview, + probeCandidates, + probes, + order +}: { + candidate: TFontCandidate + fallbackPreview: ReactNode + fallbackStack: string + isolatedPreview: ReactNode + order: number + probeCandidates: TFontIdentityCandidate[] + probes: TFontProbe[] +}): ReactElement { + return ( +
+
+
+

{`${order}. ${candidate.label}`}

+

{candidate.note}

+
+ +
+ + +
+ ) +} + +function StackPreview({ + title, + description, + stack, + preview +}: { + title: string + description: string + stack: string + preview: ReactNode +}): ReactElement { + return ( +
+
+

{title}

+

{description}

+
+ {stack} + {preview} +
+ ) +} + +function Index(): ReactElement { + const [platform, setPlatform] = useState('Unknown platform') + + useEffect(() => { + const navigatorWithUserAgentData = navigator as Navigator & { + userAgentData?: { platform?: string } + } + + setPlatform(navigatorWithUserAgentData.userAgentData?.platform || navigator.platform || 'Unknown platform') + }, []) + + return ( +
+ +
+
+

Font Fallback Debug

+
+
+

+ Compare the exact sans and mono fallback order on this machine. +

+

+ Use this page to compare the live body stack and number stack against each individual fallback + candidate. Each card shows the isolated family first, then the actual remaining fallback chain from that + point in the stack. The bold weights mirror the vault detail route, and the italic lines are included as + a deliberate stress test. The rendered-face readout is inferred by matching canvas output against known + families on this machine. +

+
+
+ {`Platform: ${platform}`} + {`Route: /font-fallbacks`} +
+
+
+ +
+ } + /> + } + /> +
+ +
+
+

Sans Candidates

+

Body fallback order, one family at a time.

+
+
+ {SANS_STACK.map((candidate, index) => ( + } + fallbackStack={buildFallbackStack(SANS_STACK, index)} + isolatedPreview={} + order={index + 1} + probeCandidates={SANS_RENDER_CANDIDATES} + probes={SANS_RENDER_PROBES} + /> + ))} +
+
+ +
+
+

Mono Candidates

+

+ Numeric and code-style fallback order, one family at a time. +

+
+
+ {MONO_STACK.map((candidate, index) => ( + } + fallbackStack={buildFallbackStack(MONO_STACK, index)} + isolatedPreview={} + order={index + 1} + probeCandidates={MONO_RENDER_CANDIDATES} + probes={MONO_RENDER_PROBES} + /> + ))} +
+
+
+
+ ) +} + +export default Index diff --git a/src/routes.tsx b/src/routes.tsx index 045207d70..8512a40c9 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,6 +8,7 @@ const PortfolioPage = lazy(() => import('@pages/portfolio/index')) const VaultsPage = lazy(() => import('@pages/vaults/index')) const VaultsDetailPage = lazy(() => import('@pages/vaults/[chainID]/[address]')) const IconListPage = lazy(() => import('@pages/icon-list/index')) +const FontFallbacksPage = lazy(() => import('@pages/font-fallbacks/index')) // Loading component const PageLoader = (): ReactElement => ( @@ -38,6 +39,9 @@ export function Routes(): ReactElement { {/* Icon inventory page */} } /> + {/* Font fallback debug page */} + } /> + {/* Unified Vaults routes */} } /> diff --git a/style.css b/style.css index 20da34ec4..dbc212e44 100755 --- a/style.css +++ b/style.css @@ -10,14 +10,11 @@ @theme { /* Font families */ --font-family-aeonik: - Aeonik, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, - Arial, sans-serif; + 'Aeonik', Helvetica, Arial, system-ui, sans-serif; --font-family-aeonik-fono: - 'Aeonik Fono', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Helvetica, Arial, sans-serif; - --font-family-aeonik-mono: 'Aeonik Mono', Consolas, 'Courier New', monospace; - --font-family-source-code-pro: - 'Source Code Pro', 'Aeonik Mono', Consolas, 'Courier New', monospace; + 'Aeonik Fono', Helvetica, Arial, system-ui, sans-serif; + --font-family-aeonik-mono: + 'Aeonik Mono', Menlo, Consolas, 'Liberation Mono', monospace; --color-black: hsl(0 0% 0%); --color-white: hsl(0 0% 100%); @@ -166,9 +163,7 @@ html { body { background-color: var(--color-app); color: var(--color-text-primary); - font-family: - aeonik, 'aeonik Fallback', 'Source Code Pro', 'Source Code Pro Fallback', - sans-serif; + font-family: 'Aeonik', Helvetica, Arial, system-ui, sans-serif; /* Mobile-optimized line height for readability */ line-height: 1.5; } @@ -181,8 +176,7 @@ body { @layer utilities { .font-number { font-family: - 'Aeonik Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - 'Liberation Mono', 'Courier New', monospace; + 'Aeonik Mono', Menlo, Consolas, 'Liberation Mono', monospace; } .scrollbar-none { diff --git a/vite.config.ts b/vite.config.ts index 8ec33d3b6..0e38b27ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,6 @@ import react from '@vitejs/plugin-react' import path from 'path' import { defineConfig } from 'vite' -import webfontDownload from 'vite-plugin-webfont-dl' const API_PROXY_TARGET = 'http://localhost:3001' const API_HEALTHCHECK_PATH = '/api/enso/balances' @@ -64,17 +63,7 @@ function previewApiGuard() { } export default defineConfig({ - plugins: [ - react(), - previewApiGuard(), - webfontDownload(['https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600;700&display=swap'], { - injectAsStyleTag: true, - minifyCss: true, - async: true, - cache: true, - proxy: false - }) - ], + plugins: [react(), previewApiGuard()], resolve: { alias: { '@': path.resolve(__dirname, './src'), From 54cb0907471edec82f71d9a8bf8ded9c0e76211b Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:48:32 -0400 Subject: [PATCH 09/15] chore: update tenderly scripts (#1145) --- .env.example | 10 + scripts/tenderly-vnet-status.test.ts | 182 ++++++ scripts/tenderly-vnet-status.ts | 930 +++++++++++++++++++++++++++ scripts/tenderly-vnet.test.ts | 138 +++- scripts/tenderly-vnet.ts | 375 ++++++++++- 5 files changed, 1619 insertions(+), 16 deletions(-) create mode 100644 scripts/tenderly-vnet-status.test.ts create mode 100644 scripts/tenderly-vnet-status.ts diff --git a/.env.example b/.env.example index b364548b3..000238c60 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,19 @@ VITE_TENDERLY_RPC_URI_FOR_1= TENDERLY_ADMIN_RPC_URI_FOR_1= VITE_TENDERLY_EXPLORER_URI_FOR_1= +WEBOPS_TENDERLY_API_KEY= +WEBOPS_ACCOUNT_SLUG= +WEBOPS_PROJECT_SLUG= +# Optional owner suffix override for derived stable RPC names like yearn-fi-webops-vnet-10-ross. +# If unset, the bootstrap script falls back to TENDERLY_RPC_OWNER or the local shell user. +WEBOPS_TENDERLY_RPC_OWNER= +WEBOPS_TENDERLY_RPC_NAME= + PERSONAL_TENDERLY_API_KEY= PERSONAL_ACCOUNT_SLUG= PERSONAL_PROJECT_SLUG= +# Optional owner suffix override for derived stable RPC names. +PERSONAL_TENDERLY_RPC_OWNER= PERSONAL_TENDERLY_RPC_NAME= VITE_ALCHEMY_KEY= diff --git a/scripts/tenderly-vnet-status.test.ts b/scripts/tenderly-vnet-status.test.ts new file mode 100644 index 000000000..dbb2ddf2e --- /dev/null +++ b/scripts/tenderly-vnet-status.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { + buildTenderlyStatusJson, + buildTenderlyStatusMarkdown, + classifyPublicRpcMode, + normalizeTenderlyTransactionListResponse, + normalizeTenderlyVnetListResponse, + resolveStableNamedPublicRpcName, + resolveTenderlyStatusIdentity, + selectMatchingTenderlyVnet +} from './tenderly-vnet-status' + +describe('tenderly-vnet-status', () => { + it('defaults to the webops profile', () => { + expect( + resolveTenderlyStatusIdentity( + {}, + { + WEBOPS_ACCOUNT_SLUG: 'yearn', + WEBOPS_PROJECT_SLUG: 'frontend' + } + ) + ).toMatchObject({ + profile: 'webops', + accountSlug: 'yearn', + projectSlug: 'frontend' + }) + }) + + it('supports explicit personal profile overrides', () => { + expect( + resolveTenderlyStatusIdentity( + { profile: 'personal' }, + { + PERSONAL_ACCOUNT_SLUG: 'me', + PERSONAL_PROJECT_SLUG: 'sandbox' + } + ) + ).toMatchObject({ + profile: 'personal', + accountSlug: 'me', + projectSlug: 'sandbox' + }) + }) + + it('normalizes array and wrapped vnet list responses', () => { + expect(normalizeTenderlyVnetListResponse([{ slug: 'vnet-a' }])).toHaveLength(1) + expect(normalizeTenderlyVnetListResponse({ vnets: [{ slug: 'vnet-b' }] })).toHaveLength(1) + expect(normalizeTenderlyVnetListResponse({ virtual_networks: [{ slug: 'vnet-c' }] })).toHaveLength(1) + }) + + it('normalizes array and wrapped transaction list responses', () => { + expect(normalizeTenderlyTransactionListResponse([{ tx_hash: '0xabc' }])).toHaveLength(1) + expect(normalizeTenderlyTransactionListResponse({ transactions: [{ tx_hash: '0xdef' }] })).toHaveLength(1) + expect(normalizeTenderlyTransactionListResponse({ data: [{ tx_hash: '0xghi' }] })).toHaveLength(1) + }) + + it('matches the active vnet by admin rpc before any weaker fallback', () => { + expect( + selectMatchingTenderlyVnet({ + vnets: [ + { + slug: 'first', + rpcs: [{ name: 'Admin RPC', url: 'https://admin-a' }] + }, + { + slug: 'second', + rpcs: [{ name: 'Admin RPC', url: 'https://admin-b' }] + } + ], + adminRpcUri: 'https://admin-b', + publicRpcUri: 'https://public-miss', + executionChainId: 694201 + }) + ).toMatchObject({ + reason: 'admin-rpc', + record: { slug: 'second' } + }) + }) + + it('classifies stable named public rpcs correctly without relying on a single profile rpc name', () => { + expect( + classifyPublicRpcMode({ + accountSlug: 'yearn', + projectSlug: 'frontend', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-42161' + }) + ).toBe('stable named endpoint') + + expect( + resolveStableNamedPublicRpcName({ + accountSlug: 'yearn', + projectSlug: 'frontend', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-42161' + }) + ).toBe('yearn-fi-webops-vnet-42161') + + expect( + classifyPublicRpcMode({ + accountSlug: 'yearn', + projectSlug: 'frontend', + rpcName: 'yearn-fi-webops-vnet', + publicRpcUri: 'https://virtual.mainnet.eu.rpc.tenderly.co/ephemeral-id' + }) + ).toBe('dynamic endpoint') + }) + + it('builds sanitized markdown and json reports without admin rpc leakage', () => { + const report = { + profile: 'webops' as const, + accountSlug: 'yearn', + projectSlug: 'frontend', + restMetadataAvailable: true, + recentTransactionCount: 5, + chainReports: [ + { + canonicalChainId: 1, + canonicalChainName: 'Ethereum', + configuredExecutionChainId: 694201, + liveExecutionChainId: 694201, + currentBlockNumber: 24_735_515, + latestBlockTimestampSeconds: 1_742_000_000, + latestBlockTimestampLabel: '2025-03-25 17:46:40 UTC', + latestBlockAgeLabel: '8s', + publicRpcUri: 'https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1', + publicRpcMode: 'stable named endpoint' as const, + publicRpcName: 'yearn-fi-webops-vnet-1', + hasAdminRpc: true, + explorerEnabled: false, + totalTransactionsAvailable: true, + totalTransactionsCount: 12, + recentTransactionsAvailable: true, + recentTransactions: [ + { + status: 'success', + functionName: 'deposit', + createdAtAgeLabel: '8s', + blockNumber: 24_735_515, + txHash: '0xb00dc057e50c8926896495cb31717bfa7ab47608673789b4b7b478ec114080cb', + from: '0x5b0d3243c78fb9d4ac035fb2384ffdf7a9ef6396', + to: '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' + } + ], + matchedVnet: { + slug: 'vnet-123', + displayName: 'Webops VNet 123', + forkNetworkId: 1, + forkBlockNumber: 24_735_515 + }, + matchReason: 'admin-rpc' as const + } + ] + } + + const markdown = buildTenderlyStatusMarkdown(report) + const json = JSON.stringify(buildTenderlyStatusJson(report)) + + expect(markdown).toContain('# Tenderly VNet Status') + expect(markdown).toContain('Profile: webops') + expect(markdown).toContain('## Ethereum (1)') + expect(markdown).toContain('ID: vnet-123') + expect(markdown).toContain('Chain ID: 694201') + expect(markdown).toContain( + 'Public RPC: https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1' + ) + expect(markdown).toContain('Public RPC Mode: stable named endpoint (yearn-fi-webops-vnet-1)') + expect(markdown).toContain('Total Transactions: 12') + expect(markdown).toContain('Admin RPC: configured') + expect(markdown).toContain('Most Recent Transactions (1 shown):') + expect(markdown).toContain('| Age | Status | Block | Method | From | To | Tx Hash |') + expect(markdown).toContain('deposit') + expect(markdown).not.toContain('https://admin') + expect(json).toContain('"slug":"vnet-123"') + expect(json).toContain('"recentTransactions"') + expect(json).toContain( + '"publicRpcUri":"https://virtual.rpc.tenderly.co/yearn/frontend/public/yearn-fi-webops-vnet-1"' + ) + expect(json).toContain('"totalTransactionsCount":12') + expect(json).toContain('"publicRpcName":"yearn-fi-webops-vnet-1"') + expect(json).not.toContain('adminRpc') + }) +}) diff --git a/scripts/tenderly-vnet-status.ts b/scripts/tenderly-vnet-status.ts new file mode 100644 index 000000000..332480921 --- /dev/null +++ b/scripts/tenderly-vnet-status.ts @@ -0,0 +1,930 @@ +/// + +import { resolve as resolvePath } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseTenderlyServerChains, type TTenderlyServerChainConfig } from '../api/tenderly.helpers' +import { buildPredictablePublicRpcUrl, readEnvFile, sanitizeConsoleText } from './tenderly-vnet' + +type TParsedCliArgs = { + flags: Record + positionals: string[] +} + +type TTenderlyProfile = 'webops' | 'personal' + +type TTenderlyStatusIdentity = { + profile: TTenderlyProfile + accountSlug: string + projectSlug: string + apiKey?: string + rpcName?: string +} + +type TTenderlyVnetRpc = { + name?: string + url?: string +} + +type TTenderlyVnetRecord = { + id?: string + slug?: string + display_name?: string + status?: string + fork_config?: { + network_id?: number | string + block_number?: number | string + } + virtual_network_config?: { + chain_config?: { + chain_id?: number | string + } + } + explorer_page_config?: { + enabled?: boolean + } + rpcs?: TTenderlyVnetRpc[] +} + +type TTenderlyStatusMatchReason = 'admin-rpc' | 'public-rpc' | 'execution-chain-id' | 'single-vnet-fallback' + +type TTenderlyMatchedVnet = { + record: TTenderlyVnetRecord + reason: TTenderlyStatusMatchReason +} + +type TTenderlyChainLiveState = { + liveExecutionChainId: number + currentBlockNumber: number + latestBlockTimestampSeconds: number +} + +type TTenderlyVnetTransactionRecord = { + id?: string + vnet_id?: string + origin?: string + category?: string + kind?: string + status?: string + rpc_method?: string + created_at?: string + block_number?: number | string + block_hash?: string + tx_hash?: string + tx_index?: number | string + from?: string + to?: string + function_name?: string + dashboard_url?: string +} + +type TTenderlyRecentTransaction = { + id?: string + status: string + category?: string + kind?: string + rpcMethod?: string + functionName?: string + createdAt?: string + createdAtAgeLabel?: string + blockNumber?: number + txHash?: string + from?: string + to?: string +} + +type TTenderlyStatusChainReport = { + canonicalChainId: number + canonicalChainName: string + configuredExecutionChainId: number + liveExecutionChainId: number + currentBlockNumber: number + latestBlockTimestampSeconds: number + latestBlockTimestampLabel: string + latestBlockAgeLabel: string + publicRpcUri: string + publicRpcMode: 'stable named endpoint' | 'dynamic endpoint' + publicRpcName?: string + hasAdminRpc: boolean + explorerEnabled: boolean + explorerUri?: string + totalTransactionsAvailable: boolean + totalTransactionsCount?: number + totalTransactionsNote?: string + recentTransactionsAvailable: boolean + recentTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + matchedVnet?: { + id?: string + slug?: string + displayName?: string + status?: string + forkNetworkId?: number + forkBlockNumber?: number + } + matchReason?: TTenderlyStatusMatchReason +} + +type TTenderlyStatusReport = { + profile: TTenderlyProfile + accountSlug: string + projectSlug: string + restMetadataAvailable: boolean + restMetadataNote?: string + recentTransactionCount: number + chainReports: TTenderlyStatusChainReport[] +} + +const DEFAULT_ACCOUNT_SLUG = 'me' +const DEFAULT_RECENT_TRANSACTION_COUNT = 5 +const TENDERLY_TRANSACTION_PAGE_SIZE = 100 +const TENDERLY_TRANSACTION_MAX_PAGES = 100 +const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' +const PROFILE_DEFAULTS = { + personal: { + apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', + accountEnvKeys: ['PERSONAL_ACCOUNT_SLUG', 'ACCOUNT_SLUG'], + projectEnvKeys: ['PERSONAL_PROJECT_SLUG', 'PROJECT_SLUG'], + rpcNameEnv: 'PERSONAL_TENDERLY_RPC_NAME' + }, + webops: { + apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', + accountEnvKeys: ['WEBOPS_ACCOUNT_SLUG', 'TENDERLY_ACCOUNT_SLUG'], + projectEnvKeys: ['WEBOPS_PROJECT_SLUG', 'TENDERLY_PROJECT_SLUG'], + rpcNameEnv: 'WEBOPS_TENDERLY_RPC_NAME' + } +} as const + +const HELP_TEXT = `Tenderly Virtual TestNet status + +Usage: + bun scripts/tenderly-vnet-status.ts [options] + +Options: + --profile Credential profile: webops or personal (default: webops) + --chain Canonical chain id to inspect (default: all configured Tenderly chains) + --account Tenderly account slug override + --project Tenderly project slug override + --api-key Tenderly API key override for metadata lookup + --api-key-env Env var name to read the API key from + --account-env Env var name to read the account slug from + --project-env Env var name to read the project slug from + --rpc-name Stable public RPC name override + --recent-tx-count Number of recent transactions to include per chain (default: 5) + --json Print a sanitized JSON report instead of Markdown + --help Show this help text + +Notes: + - Reads the active Tenderly mapping from repo .env and process.env. + - Uses Admin RPC for live chain state. + - Uses the Tenderly REST API for VNet metadata when credentials are available. + - Never prints API keys or admin RPC URLs. +` + +function parseCliArgs(argv: readonly string[]): TParsedCliArgs { + const recurse = (index: number, accumulator: TParsedCliArgs): TParsedCliArgs => { + if (index >= argv.length) { + return accumulator + } + + const token = argv[index] + if (!token.startsWith('--')) { + return recurse(index + 1, { + flags: accumulator.flags, + positionals: [...accumulator.positionals, token] + }) + } + + const key = token.slice(2) + const nextToken = argv[index + 1] + const value = nextToken && !nextToken.startsWith('--') ? nextToken : 'true' + const nextIndex = value === 'true' ? index + 1 : index + 2 + + return recurse(nextIndex, { + positionals: accumulator.positionals, + flags: { + ...accumulator.flags, + [key]: value + } + }) + } + + return recurse(0, { flags: {}, positionals: [] }) +} + +function getArg(flags: Record, ...keys: string[]): string | undefined { + return keys.map((key) => flags[key]?.trim()).find(Boolean) +} + +function getEnvValue(env: Record, key?: string): string | undefined { + if (!key) { + return undefined + } + return env[key]?.trim() +} + +function getFirstEnvValue(env: Record, keys: string[]): string | undefined { + return keys.map((key) => getEnvValue(env, key)).find(Boolean) +} + +function parseProfile(value: string | undefined): TTenderlyProfile { + const normalizedValue = value?.trim().toLowerCase() + + if (!normalizedValue) { + return 'webops' + } + + if (normalizedValue === 'webops' || normalizedValue === 'personal') { + return normalizedValue + } + + throw new Error(`Unsupported profile: ${value}. Expected "webops" or "personal".`) +} + +function parseOptionalInteger(value: string | undefined, label: string): number | undefined { + if (!value) { + return undefined + } + + const parsedValue = Number(value) + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error(`Invalid ${label}: ${value}`) + } + + return parsedValue +} + +function parseRecentTransactionCount(flags: Record): number { + const parsedValue = + parseOptionalInteger(getArg(flags, 'recent-tx-count', 'recent-transactions'), 'recent transaction count') || + DEFAULT_RECENT_TRANSACTION_COUNT + + return Math.min(Math.max(parsedValue, 1), 20) +} + +export function resolveTenderlyStatusIdentity( + flags: Record, + env: Record +): TTenderlyStatusIdentity { + const profile = parseProfile(getArg(flags, 'profile')) + const defaults = PROFILE_DEFAULTS[profile] + const accountEnv = getArg(flags, 'account-env') + const projectEnv = getArg(flags, 'project-env') + const apiKeyEnv = getArg(flags, 'api-key-env') + + return { + profile, + accountSlug: + getArg(flags, 'account') || + getEnvValue(env, accountEnv) || + getFirstEnvValue(env, [...defaults.accountEnvKeys]) || + DEFAULT_ACCOUNT_SLUG, + projectSlug: + getArg(flags, 'project') || + getEnvValue(env, projectEnv) || + getFirstEnvValue(env, [...defaults.projectEnvKeys]) || + '', + apiKey: + getArg(flags, 'api-key') || + getEnvValue(env, apiKeyEnv) || + getEnvValue(env, defaults.apiKeyEnv) || + env.TENDERLY_ACCESS_KEY?.trim() || + env.TENDERLY_API_KEY?.trim(), + rpcName: getArg(flags, 'rpc-name') || getEnvValue(env, defaults.rpcNameEnv) || env.TENDERLY_RPC_NAME?.trim() + } +} + +export function normalizeTenderlyVnetListResponse(payload: unknown): TTenderlyVnetRecord[] { + if (Array.isArray(payload)) { + return payload as TTenderlyVnetRecord[] + } + + if (!payload || typeof payload !== 'object') { + return [] + } + + const record = payload as Record + return ( + [record.virtual_networks, record.vnets] + .find(Array.isArray) + ?.filter(Boolean) + .map((item) => item as TTenderlyVnetRecord) || [] + ) +} + +export function normalizeTenderlyTransactionListResponse(payload: unknown): TTenderlyVnetTransactionRecord[] { + if (Array.isArray(payload)) { + return payload as TTenderlyVnetTransactionRecord[] + } + + if (!payload || typeof payload !== 'object') { + return [] + } + + const record = payload as Record + return ( + [record.transactions, record.results, record.data] + .find(Array.isArray) + ?.filter(Boolean) + .map((item) => item as TTenderlyVnetTransactionRecord) || [] + ) +} + +function resolveVnetRpcUrl(vnet: TTenderlyVnetRecord, rpcName: string): string | undefined { + return vnet.rpcs?.find((rpc) => rpc.name === rpcName)?.url +} + +function parseNumericString(value: number | string | undefined): number | undefined { + if (value === undefined) { + return undefined + } + + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined + } + + const normalizedValue = value.trim() + if (!normalizedValue) { + return undefined + } + + return normalizedValue.startsWith('0x') ? Number(BigInt(normalizedValue)) : Number(normalizedValue) +} + +export function selectMatchingTenderlyVnet(params: { + vnets: TTenderlyVnetRecord[] + adminRpcUri?: string + publicRpcUri?: string + executionChainId: number +}): TTenderlyMatchedVnet | undefined { + const adminRpcMatch = params.adminRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Admin RPC') === params.adminRpcUri) + : undefined + + if (adminRpcMatch) { + return { record: adminRpcMatch, reason: 'admin-rpc' } + } + + const publicRpcMatch = params.publicRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Public RPC') === params.publicRpcUri) + : undefined + + if (publicRpcMatch) { + return { record: publicRpcMatch, reason: 'public-rpc' } + } + + const executionChainMatch = params.vnets.find( + (vnet) => parseNumericString(vnet.virtual_network_config?.chain_config?.chain_id) === params.executionChainId + ) + + if (executionChainMatch) { + return { record: executionChainMatch, reason: 'execution-chain-id' } + } + + return params.vnets.length === 1 ? { record: params.vnets[0], reason: 'single-vnet-fallback' } : undefined +} + +export function classifyPublicRpcMode(params: { + accountSlug: string + projectSlug: string + rpcName?: string + publicRpcUri: string +}): 'stable named endpoint' | 'dynamic endpoint' { + if (resolveStableNamedPublicRpcName(params)) { + return 'stable named endpoint' + } + + if (!params.rpcName) { + return 'dynamic endpoint' + } + + const predictablePublicRpc = buildPredictablePublicRpcUrl(params.accountSlug, params.projectSlug, params.rpcName) + return predictablePublicRpc === params.publicRpcUri ? 'stable named endpoint' : 'dynamic endpoint' +} + +export function resolveStableNamedPublicRpcName(params: { + accountSlug: string + projectSlug: string + rpcName?: string + publicRpcUri: string +}): string | undefined { + try { + const parsedUrl = new URL(params.publicRpcUri) + if (parsedUrl.hostname !== 'virtual.rpc.tenderly.co') { + return undefined + } + + const pathSegments = parsedUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length !== 4) { + return undefined + } + + const [accountSlug, projectSlug, visibility, rpcName] = pathSegments + if ( + accountSlug !== params.accountSlug || + projectSlug !== params.projectSlug || + visibility !== 'public' || + !rpcName + ) { + return undefined + } + + return rpcName + } catch { + return undefined + } +} + +function formatTimestampUtc(timestampSeconds: number): string { + return new Date(timestampSeconds * 1000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ' UTC') +} + +function formatRelativeAgeLabel(timestampSeconds: number): string { + const elapsedSeconds = Math.max(0, Math.round(Date.now() / 1000 - timestampSeconds)) + + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s` + } + + if (elapsedSeconds < 3_600) { + return `${Math.round(elapsedSeconds / 60)}m` + } + + if (elapsedSeconds < 86_400) { + return `${Math.round(elapsedSeconds / 3_600)}h` + } + + return `${Math.round(elapsedSeconds / 86_400)}d` +} + +function formatIsoAgeLabel(isoTimestamp: string | undefined): string | undefined { + if (!isoTimestamp) { + return undefined + } + + const timestampMs = Date.parse(isoTimestamp) + if (!Number.isFinite(timestampMs)) { + return undefined + } + + return formatRelativeAgeLabel(Math.round(timestampMs / 1000)) +} + +function shortenHex(value: string | undefined, prefixLength: number, suffixLength: number): string { + if (!value) { + return 'n/a' + } + + const normalizedValue = value.trim() + if (normalizedValue.length <= prefixLength + suffixLength + 1) { + return normalizedValue + } + + return `${normalizedValue.slice(0, prefixLength)}…${normalizedValue.slice(-suffixLength)}` +} + +function formatTransactionLabel(transaction: TTenderlyRecentTransaction): string { + return transaction.functionName || transaction.rpcMethod || transaction.kind || transaction.category || 'unknown' +} + +function formatRecentTransactionRow(transaction: TTenderlyRecentTransaction): string { + return `| ${transaction.createdAtAgeLabel || 'n/a'} | ${transaction.status} | ${ + transaction.blockNumber?.toLocaleString('en-US') || 'n/a' + } | ${formatTransactionLabel(transaction)} | ${shortenHex(transaction.from, 8, 4)} | ${shortenHex( + transaction.to, + 8, + 4 + )} | ${shortenHex(transaction.txHash, 10, 6)} |` +} + +async function fetchJsonRpcResult(adminRpcUri: string, method: string, params: unknown[]): Promise { + const response = await fetch(adminRpcUri, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method, + params + }) + }) + + if (!response.ok) { + throw new Error(`Tenderly Admin RPC request failed (${response.status})`) + } + + const payload = (await response.json()) as { result?: T; error?: { code: number; message: string } } | undefined + + if (payload?.error) { + throw new Error(sanitizeConsoleText(`${payload.error.message} (code ${payload.error.code})`)) + } + + return payload?.result as T +} + +async function fetchTenderlyVnets(identity: TTenderlyStatusIdentity): Promise { + if (!identity.apiKey || !identity.projectSlug) { + return [] + } + + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(identity.accountSlug)}/project/${encodeURIComponent(identity.projectSlug)}/vnets`, + { + headers: { + Accept: 'application/json', + 'X-Access-Key': identity.apiKey + } + } + ) + + if (!response.ok) { + throw new Error(`Tenderly REST API request failed (${response.status})`) + } + + return normalizeTenderlyVnetListResponse(await response.json()) +} + +async function fetchTenderlyRecentTransactions(params: { + identity: TTenderlyStatusIdentity + vnetId?: string + recentCount: number +}): Promise<{ + recentTransactions: TTenderlyRecentTransaction[] + totalTransactionsCount: number + totalTransactionsNote?: string +}> { + if (!params.identity.apiKey || !params.identity.projectSlug || !params.vnetId) { + return { + recentTransactions: [], + totalTransactionsCount: 0 + } + } + + const recentTransactions: TTenderlyRecentTransaction[] = [] + let totalTransactionsCount = 0 + + for (let page = 1; page <= TENDERLY_TRANSACTION_MAX_PAGES; page += 1) { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(params.identity.accountSlug)}/project/${encodeURIComponent( + params.identity.projectSlug + )}/vnets/${encodeURIComponent(params.vnetId)}/transactions?page=${page}&per_page=${TENDERLY_TRANSACTION_PAGE_SIZE}`, + { + headers: { + Accept: 'application/json', + 'X-Access-Key': params.identity.apiKey + } + } + ) + + if (!response.ok) { + throw new Error(`Tenderly recent transactions request failed (${response.status})`) + } + + const pageTransactions = normalizeTenderlyTransactionListResponse(await response.json()).map((transaction) => ({ + id: transaction.id, + status: transaction.status || 'unknown', + category: transaction.category, + kind: transaction.kind, + rpcMethod: transaction.rpc_method, + functionName: transaction.function_name, + createdAt: transaction.created_at, + createdAtAgeLabel: formatIsoAgeLabel(transaction.created_at), + blockNumber: parseNumericString(transaction.block_number), + txHash: transaction.tx_hash, + from: transaction.from, + to: transaction.to + })) + + totalTransactionsCount += pageTransactions.length + + if (page === 1) { + recentTransactions.push(...pageTransactions.slice(0, params.recentCount)) + } + + if (pageTransactions.length < TENDERLY_TRANSACTION_PAGE_SIZE) { + return { + recentTransactions, + totalTransactionsCount + } + } + } + + const totalTransactionsNote = `count truncated after ${( + TENDERLY_TRANSACTION_MAX_PAGES * TENDERLY_TRANSACTION_PAGE_SIZE + ).toLocaleString('en-US')} transactions` + + return { + recentTransactions, + totalTransactionsCount, + totalTransactionsNote + } +} + +async function fetchTenderlyChainLiveState(chain: TTenderlyServerChainConfig): Promise { + const [chainIdHex, blockNumberHex, latestBlock] = await Promise.all([ + fetchJsonRpcResult(chain.adminRpcUri || '', 'eth_chainId', []), + fetchJsonRpcResult(chain.adminRpcUri || '', 'eth_blockNumber', []), + fetchJsonRpcResult<{ timestamp?: string }>(chain.adminRpcUri || '', 'eth_getBlockByNumber', ['latest', false]) + ]) + + const liveExecutionChainId = Number(BigInt(chainIdHex)) + const currentBlockNumber = Number(BigInt(blockNumberHex)) + const latestBlockTimestampSeconds = Number(BigInt(latestBlock.timestamp || '0x0')) + + return { + liveExecutionChainId, + currentBlockNumber, + latestBlockTimestampSeconds + } +} + +function buildChainReport(params: { + identity: TTenderlyStatusIdentity + chain: TTenderlyServerChainConfig + liveState: TTenderlyChainLiveState + matchedVnet?: TTenderlyMatchedVnet + recentTransactionsResult: + | { + recentTransactionsAvailable: true + totalTransactionsCount: number + totalTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + recentTransactionsNote?: string + } + | { + recentTransactionsAvailable: false + totalTransactionsCount?: number + totalTransactionsNote?: string + recentTransactions: TTenderlyRecentTransaction[] + recentTransactionsNote?: string + } + env: Record +}): TTenderlyStatusChainReport { + const explorerUri = params.env[`VITE_TENDERLY_EXPLORER_URI_FOR_${params.chain.canonicalChainId}`]?.trim() || undefined + const forkNetworkId = parseNumericString(params.matchedVnet?.record.fork_config?.network_id) + const forkBlockNumber = parseNumericString(params.matchedVnet?.record.fork_config?.block_number) + + return { + canonicalChainId: params.chain.canonicalChainId, + canonicalChainName: params.chain.canonicalChainName, + configuredExecutionChainId: params.chain.executionChainId, + liveExecutionChainId: params.liveState.liveExecutionChainId, + currentBlockNumber: params.liveState.currentBlockNumber, + latestBlockTimestampSeconds: params.liveState.latestBlockTimestampSeconds, + latestBlockTimestampLabel: formatTimestampUtc(params.liveState.latestBlockTimestampSeconds), + latestBlockAgeLabel: formatRelativeAgeLabel(params.liveState.latestBlockTimestampSeconds), + publicRpcUri: params.chain.rpcUri, + publicRpcName: resolveStableNamedPublicRpcName({ + accountSlug: params.identity.accountSlug, + projectSlug: params.identity.projectSlug, + rpcName: params.identity.rpcName, + publicRpcUri: params.chain.rpcUri + }), + publicRpcMode: classifyPublicRpcMode({ + accountSlug: params.identity.accountSlug, + projectSlug: params.identity.projectSlug, + rpcName: params.identity.rpcName, + publicRpcUri: params.chain.rpcUri + }), + hasAdminRpc: Boolean(params.chain.adminRpcUri), + explorerEnabled: Boolean(params.matchedVnet?.record.explorer_page_config?.enabled || explorerUri), + explorerUri, + totalTransactionsAvailable: params.recentTransactionsResult.recentTransactionsAvailable, + totalTransactionsCount: params.recentTransactionsResult.totalTransactionsCount, + totalTransactionsNote: params.recentTransactionsResult.totalTransactionsNote, + recentTransactionsAvailable: params.recentTransactionsResult.recentTransactionsAvailable, + recentTransactionsNote: params.recentTransactionsResult.recentTransactionsNote, + recentTransactions: params.recentTransactionsResult.recentTransactions, + matchedVnet: params.matchedVnet + ? { + id: params.matchedVnet.record.id, + slug: params.matchedVnet.record.slug, + displayName: params.matchedVnet.record.display_name, + status: params.matchedVnet.record.status, + forkNetworkId, + forkBlockNumber + } + : undefined, + matchReason: params.matchedVnet?.reason + } +} + +export function buildTenderlyStatusMarkdown(report: TTenderlyStatusReport): string { + const headerLines = [ + '# Tenderly VNet Status', + '', + `Profile: ${report.profile}`, + `Account / Project: ${report.accountSlug} / ${report.projectSlug || '[unset]'}`, + `Metadata: ${report.restMetadataAvailable ? 'available' : report.restMetadataNote || 'unavailable'}` + ] + + const chainSections = report.chainReports.flatMap((chainReport) => { + const transactionSection = + chainReport.recentTransactions.length > 0 + ? [ + '', + `Most Recent Transactions (${Math.min(chainReport.recentTransactions.length, report.recentTransactionCount)} shown):`, + '', + '| Age | Status | Block | Method | From | To | Tx Hash |', + '| --- | --- | ---: | --- | --- | --- | --- |', + ...chainReport.recentTransactions.map(formatRecentTransactionRow) + ] + : [ + '', + `Most Recent Transactions: ${ + chainReport.recentTransactionsAvailable + ? chainReport.recentTransactionsNote || 'none' + : chainReport.recentTransactionsNote || 'unavailable' + }` + ] + + return [ + '', + `## ${chainReport.canonicalChainName} (${chainReport.canonicalChainId})`, + `ID: ${chainReport.matchedVnet?.slug || 'metadata unavailable'}`, + `UUID: ${chainReport.matchedVnet?.id || 'metadata unavailable'}`, + `Display Name: ${chainReport.matchedVnet?.displayName || 'metadata unavailable'}`, + `Chain ID: ${chainReport.liveExecutionChainId}`, + `Configured Chain ID: ${chainReport.configuredExecutionChainId}`, + `Public RPC: ${chainReport.publicRpcUri}`, + `Public RPC Mode: ${ + chainReport.publicRpcName + ? `${chainReport.publicRpcMode} (${chainReport.publicRpcName})` + : chainReport.publicRpcMode + }`, + `Current Block: ${chainReport.currentBlockNumber.toLocaleString('en-US')}`, + `Total Transactions: ${ + chainReport.totalTransactionsAvailable + ? `${chainReport.totalTransactionsCount?.toLocaleString('en-US') || 0}${ + chainReport.totalTransactionsNote ? ` (${chainReport.totalTransactionsNote})` : '' + }` + : chainReport.totalTransactionsNote || 'unavailable' + }`, + `Latest Block Time: ${chainReport.latestBlockTimestampLabel}`, + `Block Age: ~${chainReport.latestBlockAgeLabel}`, + `Fork: ${ + chainReport.matchedVnet?.forkNetworkId && chainReport.matchedVnet?.forkBlockNumber + ? `${chainReport.matchedVnet.forkNetworkId} @ ${chainReport.matchedVnet.forkBlockNumber.toLocaleString('en-US')}` + : 'metadata unavailable' + }`, + `Admin RPC: ${chainReport.hasAdminRpc ? 'configured' : 'missing'}`, + `Explorer: ${chainReport.explorerEnabled ? chainReport.explorerUri || 'enabled' : 'disabled'}`, + `Repo Mapping: ${chainReport.matchReason ? `matched via ${chainReport.matchReason}` : 'configured'}`, + ...transactionSection + ] + }) + + return [...headerLines, ...chainSections].join('\n') +} + +export function buildTenderlyStatusJson(report: TTenderlyStatusReport): Record { + return { + profile: report.profile, + accountSlug: report.accountSlug, + projectSlug: report.projectSlug, + restMetadataAvailable: report.restMetadataAvailable, + restMetadataNote: report.restMetadataNote || null, + recentTransactionCount: report.recentTransactionCount, + chains: report.chainReports.map((chainReport) => ({ + canonicalChainId: chainReport.canonicalChainId, + canonicalChainName: chainReport.canonicalChainName, + configuredExecutionChainId: chainReport.configuredExecutionChainId, + liveExecutionChainId: chainReport.liveExecutionChainId, + currentBlockNumber: chainReport.currentBlockNumber, + latestBlockTimestampSeconds: chainReport.latestBlockTimestampSeconds, + latestBlockTimestampLabel: chainReport.latestBlockTimestampLabel, + latestBlockAgeLabel: chainReport.latestBlockAgeLabel, + publicRpcUri: chainReport.publicRpcUri, + publicRpcMode: chainReport.publicRpcMode, + publicRpcName: chainReport.publicRpcName || null, + hasAdminRpc: chainReport.hasAdminRpc, + explorerEnabled: chainReport.explorerEnabled, + explorerUri: chainReport.explorerUri || null, + totalTransactionsAvailable: chainReport.totalTransactionsAvailable, + totalTransactionsCount: chainReport.totalTransactionsCount ?? null, + totalTransactionsNote: chainReport.totalTransactionsNote || null, + recentTransactionsAvailable: chainReport.recentTransactionsAvailable, + recentTransactionsNote: chainReport.recentTransactionsNote || null, + recentTransactions: chainReport.recentTransactions, + matchedVnet: chainReport.matchedVnet || null, + matchReason: chainReport.matchReason || null + })) + } +} + +async function buildTenderlyStatusReport( + flags: Record, + env: Record +): Promise { + const identity = resolveTenderlyStatusIdentity(flags, env) + const recentTransactionCount = parseRecentTransactionCount(flags) + if (!identity.projectSlug) { + throw new Error(`Missing required value: ${identity.profile} project slug`) + } + + const requestedChainId = parseOptionalInteger(getArg(flags, 'chain'), 'chain') + const configuredChains = parseTenderlyServerChains(env).filter( + (chain) => requestedChainId === undefined || chain.canonicalChainId === requestedChainId + ) + + if (configuredChains.length === 0) { + throw new Error( + requestedChainId === undefined + ? 'No Tenderly chains are configured in the current environment' + : `Tenderly chain ${requestedChainId} is not configured` + ) + } + + const vnetResult = await fetchTenderlyVnets(identity) + .then((vnets) => ({ + vnets, + restMetadataAvailable: vnets.length > 0, + restMetadataNote: vnets.length > 0 ? undefined : 'available but empty' + })) + .catch((error) => ({ + vnets: [] as TTenderlyVnetRecord[], + restMetadataAvailable: false, + restMetadataNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)) + })) + + const chainReports = await Promise.all( + configuredChains.map(async (chain) => { + const matchedVnet = selectMatchingTenderlyVnet({ + vnets: vnetResult.vnets, + adminRpcUri: chain.adminRpcUri, + publicRpcUri: chain.rpcUri, + executionChainId: chain.executionChainId + }) + const [liveState, recentTransactionsResult] = await Promise.all([ + fetchTenderlyChainLiveState(chain), + fetchTenderlyRecentTransactions({ + identity, + vnetId: matchedVnet?.record.id, + recentCount: recentTransactionCount + }) + .then(({ recentTransactions, totalTransactionsCount, totalTransactionsNote }) => ({ + recentTransactionsAvailable: true as const, + totalTransactionsCount, + totalTransactionsNote, + recentTransactions, + recentTransactionsNote: recentTransactions.length > 0 ? undefined : 'none' + })) + .catch((error) => ({ + recentTransactionsAvailable: false as const, + totalTransactionsCount: undefined, + totalTransactionsNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)), + recentTransactions: [] as TTenderlyRecentTransaction[], + recentTransactionsNote: sanitizeConsoleText(error instanceof Error ? error.message : String(error)) + })) + ]) + + return buildChainReport({ + identity, + chain, + liveState, + matchedVnet, + recentTransactionsResult, + env + }) + }) + ) + + return { + profile: identity.profile, + accountSlug: identity.accountSlug, + projectSlug: identity.projectSlug, + restMetadataAvailable: vnetResult.restMetadataAvailable, + restMetadataNote: vnetResult.restMetadataNote, + recentTransactionCount, + chainReports + } +} + +async function main(): Promise { + const parsedArgs = parseCliArgs(process.argv.slice(2)) + const { flags } = parsedArgs + + if ('help' in flags || 'h' in flags) { + console.log(HELP_TEXT) + return + } + + const scriptDir = resolvePath(fileURLToPath(import.meta.url), '..') + const envFromFile = readEnvFile(resolvePath(scriptDir, '../.env')) + const env = { ...envFromFile, ...process.env } + const report = await buildTenderlyStatusReport(flags, env) + + if ('json' in flags) { + console.log(JSON.stringify(buildTenderlyStatusJson(report), null, 2)) + return + } + + console.log(buildTenderlyStatusMarkdown(report)) +} + +main().catch((error) => { + console.error(error instanceof Error ? sanitizeConsoleText(error.message) : sanitizeConsoleText(String(error))) + process.exitCode = 1 +}) diff --git a/scripts/tenderly-vnet.test.ts b/scripts/tenderly-vnet.test.ts index faad488f3..ae4febcd8 100644 --- a/scripts/tenderly-vnet.test.ts +++ b/scripts/tenderly-vnet.test.ts @@ -7,8 +7,14 @@ import { buildTenderlyApiErrorMessage, buildTenderlyEnvFragment, buildVnetConsoleSummary, + normalizeTenderlyRpcNameComponent, resolveExplorerUriFromResponse, + resolveRequiredRpcName, + resolveRpcOwner, + resolveTenderlyCredentials, sanitizeConsoleText, + suggestTenderlyRpcName, + suggestTenderlyRpcOwner, validateWritableOutputPath, writeOutputFile } from './tenderly-vnet' @@ -31,6 +37,130 @@ const response = { } describe('tenderly-vnet console safety', () => { + it('prefers webops account and project slugs over legacy and personal env vars', () => { + expect( + resolveTenderlyCredentials( + { profile: 'webops' }, + { + WEBOPS_TENDERLY_API_KEY: 'webops-key', + WEBOPS_ACCOUNT_SLUG: 'webops-account', + WEBOPS_PROJECT_SLUG: 'webops-project', + TENDERLY_ACCOUNT_SLUG: 'legacy-account', + TENDERLY_PROJECT_SLUG: 'legacy-project', + PERSONAL_ACCOUNT_SLUG: 'personal-account', + PERSONAL_PROJECT_SLUG: 'personal-project' + } + ) + ).toMatchObject({ + apiKey: 'webops-key', + accountSlug: 'webops-account', + projectSlug: 'webops-project', + profile: 'webops' + }) + }) + + it('falls back to legacy webops slug env vars when WEBOPS slugs are unset', () => { + expect( + resolveTenderlyCredentials( + { profile: 'webops' }, + { + WEBOPS_TENDERLY_API_KEY: 'webops-key', + TENDERLY_ACCOUNT_SLUG: 'legacy-account', + TENDERLY_PROJECT_SLUG: 'legacy-project' + } + ) + ).toMatchObject({ + accountSlug: 'legacy-account', + projectSlug: 'legacy-project' + }) + }) + + it('suggests stable rpc names by profile', () => { + expect(suggestTenderlyRpcName('webops', { networkId: 10, owner: 'ross' })).toBe('yearn-fi-webops-vnet-10-ross') + expect(suggestTenderlyRpcName('personal', { networkId: 8453 })).toBe('yearn-fi-personal-vnet-8453') + }) + + it('normalizes owner suffixes and resolves profile-specific owner env vars', () => { + expect(normalizeTenderlyRpcNameComponent('Ross Yearn')).toBe('ross-yearn') + expect( + resolveRpcOwner( + { profile: 'webops' }, + { + WEBOPS_TENDERLY_RPC_OWNER: 'Ross' + }, + 'webops' + ) + ).toBe('ross') + expect(suggestTenderlyRpcOwner({ USER: 'Ross' })).toBe('ross') + }) + + it('requires a stable rpc owner in non-interactive webops mode when no explicit rpc name is configured', async () => { + await expect( + resolveRequiredRpcName({ + flags: {}, + env: {}, + profile: 'webops', + networkId: 10, + canPrompt: false + }) + ).rejects.toThrow('Missing Tenderly RPC owner') + }) + + it('derives stable rpc names from owner-aware defaults and prompts for webops owner when needed', async () => { + await expect( + resolveRequiredRpcName({ + flags: {}, + env: { + WEBOPS_TENDERLY_RPC_OWNER: 'Ross' + }, + profile: 'webops', + networkId: 10 + }) + ).resolves.toBe('yearn-fi-webops-vnet-10-ross') + + await expect( + resolveRequiredRpcName({ + flags: {}, + env: { + USER: 'Ross' + }, + profile: 'webops', + networkId: 10 + }) + ).resolves.toBe('yearn-fi-webops-vnet-10-ross') + + await expect( + resolveRequiredRpcName({ + flags: {}, + env: {}, + profile: 'webops', + networkId: 10, + canPrompt: true, + promptOwner: async () => '' + }) + ).resolves.toBe('yearn-fi-webops-vnet-10-yourname') + + await expect( + resolveRequiredRpcName({ + flags: {}, + env: {}, + profile: 'webops', + networkId: 10, + canPrompt: true, + promptOwner: async () => 'Ross' + }) + ).resolves.toBe('yearn-fi-webops-vnet-10-ross') + + await expect( + resolveRequiredRpcName({ + flags: {}, + env: {}, + profile: 'personal', + networkId: 8453 + }) + ).resolves.toBe('yearn-fi-personal-vnet-8453') + }) + it('redacts URLs in console text', () => { expect(sanitizeConsoleText('failed at https://admin.rpc/secret-path')).toBe('failed at [redacted-url]') }) @@ -68,7 +198,8 @@ describe('tenderly-vnet console safety', () => { chainId: 694201, networkId: 1, response, - details: connectionDetails + details: connectionDetails, + rpcName: 'yearn-fi-personal-vnet-1' }).join('\n') expect(summary).toContain('Created Tenderly Virtual TestNet') @@ -87,6 +218,7 @@ describe('tenderly-vnet console safety', () => { networkId: 1, response, details: connectionDetails, + rpcName: 'yearn-fi-webops-vnet-1-ross', envFilePath: '/tmp/tenderly-vnet.env', responseFilePath: '/tmp/tenderly-vnet.json' }).join('\n') @@ -105,13 +237,15 @@ describe('tenderly-vnet console safety', () => { chainId: 694201, networkId: 1, response, - details: connectionDetails + details: connectionDetails, + rpcName: 'yearn-fi-personal-vnet-1' }) const serialized = JSON.stringify(sanitized) expect(serialized).toContain('"has_admin_rpc":true') expect(serialized).toContain('"has_public_rpc":true') + expect(serialized).toContain('"rpc_name"') expect(serialized).not.toContain('"rpcs"') expect(serialized).not.toContain(connectionDetails.adminRpc) expect(serialized).not.toContain(connectionDetails.publicRpc) diff --git a/scripts/tenderly-vnet.ts b/scripts/tenderly-vnet.ts index 1a092db96..11fb85490 100644 --- a/scripts/tenderly-vnet.ts +++ b/scripts/tenderly-vnet.ts @@ -2,6 +2,7 @@ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' +import { createInterface } from 'node:readline/promises' import { fileURLToPath } from 'node:url' type TParsedCliArgs = { @@ -18,12 +19,32 @@ type TResolvedTenderlyCredentials = { profile: TTenderlyProfile } +type TTenderlyProfileDefaults = { + apiKeyEnv: string + accountEnvKeys: string[] + projectEnvKeys: string[] + rpcNameEnv: string + rpcOwnerEnv: string +} + type TTenderlyVnetResponse = { slug?: string display_name?: string chain_id?: number network_id?: number id?: string + fork_config?: { + network_id?: number | string + block_number?: number | string + } + virtual_network_config?: { + chain_config?: { + chain_id?: number | string + } + } + explorer_page_config?: { + enabled?: boolean + } rpcs?: Array<{ name: string url: string @@ -38,6 +59,13 @@ type TTenderlyVnetConnectionDetails = { explorerUri?: string } +type TTenderlyCurrentChainMapping = { + canonicalChainId: number + executionChainId?: number + adminRpcUri?: string + publicRpcUri?: string +} + type TCreateVnetPayload = { slug: string display_name: string @@ -66,8 +94,28 @@ type TCreateVnetPayload = { const DEFAULT_ACCOUNT_SLUG = 'me' const DEFAULT_NETWORK_ID = 1 const DEFAULT_CHAIN_ID_PREFIX = '69420' +const DEFAULT_RPC_NAMES: Record = { + webops: 'yearn-fi-webops-vnet', + personal: 'yearn-fi-personal-vnet' +} const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' const REDACTED_URL = '[redacted-url]' +const TENDERLY_PROFILE_DEFAULTS: Record = { + personal: { + apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', + accountEnvKeys: ['PERSONAL_ACCOUNT_SLUG', 'ACCOUNT_SLUG'], + projectEnvKeys: ['PERSONAL_PROJECT_SLUG', 'PROJECT_SLUG'], + rpcNameEnv: 'PERSONAL_TENDERLY_RPC_NAME', + rpcOwnerEnv: 'PERSONAL_TENDERLY_RPC_OWNER' + }, + webops: { + apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', + accountEnvKeys: ['WEBOPS_ACCOUNT_SLUG', 'TENDERLY_ACCOUNT_SLUG'], + projectEnvKeys: ['WEBOPS_PROJECT_SLUG', 'TENDERLY_PROJECT_SLUG'], + rpcNameEnv: 'WEBOPS_TENDERLY_RPC_NAME', + rpcOwnerEnv: 'WEBOPS_TENDERLY_RPC_OWNER' + } +} const HELP_TEXT = `Tenderly Virtual TestNet bootstrap @@ -87,20 +135,26 @@ Options: --network-id Parent network id (default: 1) --block-number Fork block number or latest (default: latest) --chain-id Execution chain id (default: 69420) - --rpc-name Stable public RPC name for predictable endpoint reuse + --rpc-name Stable public RPC name override for predictable endpoint reuse + --rpc-owner Owner suffix used to derive the default stable public RPC name --enable-sync Enable state sync (default: false) --enable-explorer Enable public explorer (default: false) + --keep-previous Keep the currently configured VNet instead of deleting it after a successful replacement --json Print a sanitized JSON summary --write-env Write the generated Tenderly env fragment to a local file --write-response Write the raw Tenderly API response JSON to a local file --help Show this help text Profiles: - webops -> WEBOPS_TENDERLY_API_KEY, TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME - personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME + webops -> WEBOPS_TENDERLY_API_KEY, WEBOPS_ACCOUNT_SLUG, WEBOPS_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME, WEBOPS_TENDERLY_RPC_OWNER + legacy slug fallback: TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG + personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME, PERSONAL_TENDERLY_RPC_OWNER Sensitive RPC values are never printed to stdout. Use --write-env or --write-response when you need them locally. Explicit flags always win over profile defaults. +If no explicit stable RPC name is configured, the script derives one from profile + chain + owner. +For webops, owner resolution prefers WEBOPS_TENDERLY_RPC_OWNER, then TENDERLY_RPC_OWNER, then the local shell user. +If no owner can be inferred, the script prompts in interactive shells and otherwise fails with a suggested owner. ` function parseProfile(value: string | undefined): TTenderlyProfile { @@ -142,7 +196,7 @@ function parseCliArgs(argv: readonly string[]): TParsedCliArgs { return recurse(0, { flags: {}, positionals: [] }) } -function readEnvFile(path = '.env'): Record { +export function readEnvFile(path = '.env'): Record { if (!existsSync(path)) return {} const envLines = readFileSync(path, 'utf8').split('\n') @@ -212,11 +266,15 @@ function getEnvValue(env: Record, key?: string): str return env[key]?.trim() } +function getFirstEnvValue(env: Record, keys: string[]): string | undefined { + return keys.map((key) => env[key]?.trim()).find(Boolean) +} + export function sanitizeConsoleText(value: string): string { return value.replace(/https?:\/\/\S+/gi, REDACTED_URL) } -function resolveTenderlyCredentials( +export function resolveTenderlyCredentials( flags: Record, env: Record ): TResolvedTenderlyCredentials { @@ -268,16 +326,117 @@ function resolveTenderlyCredentials( } } -function resolveRpcName( +export function resolveRpcName( + flags: Record, + env: Record, + profile: TTenderlyProfile +): string | undefined { + const profileDefaults = TENDERLY_PROFILE_DEFAULTS[profile] + return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaults.rpcNameEnv) || env.TENDERLY_RPC_NAME?.trim() +} + +export function normalizeTenderlyRpcNameComponent(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export function resolveRpcOwner( flags: Record, env: Record, profile: TTenderlyProfile ): string | undefined { - const profileDefaultEnvKey = profile === 'personal' ? 'PERSONAL_TENDERLY_RPC_NAME' : 'WEBOPS_TENDERLY_RPC_NAME' - return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaultEnvKey) || env.TENDERLY_RPC_NAME?.trim() + const profileDefaults = TENDERLY_PROFILE_DEFAULTS[profile] + const rawValue = + getArg(flags, 'rpc-owner') || getEnvValue(env, profileDefaults.rpcOwnerEnv) || env.TENDERLY_RPC_OWNER?.trim() + + if (!rawValue) { + return undefined + } + + const normalizedValue = normalizeTenderlyRpcNameComponent(rawValue) + return normalizedValue || undefined +} + +export function suggestTenderlyRpcOwner(env: Record): string | undefined { + const rawValue = getFirstEnvValue(env, ['TENDERLY_RPC_OWNER', 'USER', 'LOGNAME', 'USERNAME']) + if (!rawValue) { + return undefined + } + + const normalizedValue = normalizeTenderlyRpcNameComponent(rawValue) + return normalizedValue || undefined +} + +export function suggestTenderlyRpcName( + profile: TTenderlyProfile, + params?: { + networkId?: number + owner?: string + } +): string { + const suffixParts = [params?.networkId?.toString(), params?.owner].filter(Boolean) + return [DEFAULT_RPC_NAMES[profile], ...suffixParts].join('-') +} + +export async function resolveRequiredRpcName(params: { + flags: Record + env: Record + profile: TTenderlyProfile + networkId: number + canPrompt?: boolean + promptOwner?: (suggestedOwner: string, suggestedRpcName: string) => Promise +}): Promise { + const configuredRpcName = resolveRpcName(params.flags, params.env, params.profile) + if (configuredRpcName) { + return configuredRpcName + } + + const configuredOwner = resolveRpcOwner(params.flags, params.env, params.profile) + if (configuredOwner) { + return suggestTenderlyRpcName(params.profile, { + networkId: params.networkId, + owner: configuredOwner + }) + } + + if (params.profile !== 'webops') { + return suggestTenderlyRpcName(params.profile, { networkId: params.networkId }) + } + + const suggestedOwner = suggestTenderlyRpcOwner(params.env) + if (suggestedOwner) { + return suggestTenderlyRpcName(params.profile, { + networkId: params.networkId, + owner: suggestedOwner + }) + } + + const fallbackSuggestedOwner = 'yourname' + const suggestedRpcName = suggestTenderlyRpcName(params.profile, { + networkId: params.networkId, + owner: fallbackSuggestedOwner + }) + + if (params.canPrompt && params.promptOwner) { + const promptedValue = normalizeTenderlyRpcNameComponent( + await params.promptOwner(fallbackSuggestedOwner, suggestedRpcName) + ) + return suggestTenderlyRpcName(params.profile, { + networkId: params.networkId, + owner: promptedValue || fallbackSuggestedOwner + }) + } + + throw new Error( + `Missing Tenderly RPC owner. Set WEBOPS_TENDERLY_RPC_OWNER, pass --rpc-owner, or use the suggested value: ${fallbackSuggestedOwner}. The derived stable RPC name would be: ${suggestedRpcName}` + ) } -function buildPredictablePublicRpcUrl(accountSlug: string, projectSlug: string, rpcName: string): string { +export function buildPredictablePublicRpcUrl(accountSlug: string, projectSlug: string, rpcName: string): string { return `https://virtual.rpc.tenderly.co/${encodeURIComponent(accountSlug)}/${encodeURIComponent(projectSlug)}/public/${encodeURIComponent(rpcName)}` } @@ -337,6 +496,81 @@ function getConnectionDetails( } } +function resolveVnetRpcUrl(vnet: TTenderlyVnetResponse, rpcName: string): string | undefined { + return vnet.rpcs?.find((rpc) => rpc.name === rpcName)?.url +} + +function normalizeVnetListResponse(responseBody: string): TTenderlyVnetResponse[] { + const parsed = parseResponseBody(responseBody) as unknown + + if (Array.isArray(parsed)) { + return parsed.filter((entry): entry is TTenderlyVnetResponse => isRecord(entry)) + } + + if (isRecord(parsed)) { + const nestedList = [parsed.vnets, parsed.virtual_networks, parsed.data].find(Array.isArray) + if (nestedList) { + return nestedList.filter((entry): entry is TTenderlyVnetResponse => isRecord(entry)) + } + } + + return [] +} + +function resolveCurrentChainMapping( + env: Record, + canonicalChainId: number +): TTenderlyCurrentChainMapping | undefined { + const executionChainId = parseOptionalInteger( + getEnvValue(env, `VITE_TENDERLY_CHAIN_ID_FOR_${canonicalChainId}`), + `VITE_TENDERLY_CHAIN_ID_FOR_${canonicalChainId}` + ) + const adminRpcUri = getEnvValue(env, `TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}`) + const publicRpcUri = getEnvValue(env, `VITE_TENDERLY_RPC_URI_FOR_${canonicalChainId}`) + + if (!executionChainId && !adminRpcUri && !publicRpcUri) { + return undefined + } + + return { + canonicalChainId, + executionChainId, + adminRpcUri, + publicRpcUri + } +} + +function selectMatchingConfiguredVnet(params: { + vnets: TTenderlyVnetResponse[] + currentMapping: TTenderlyCurrentChainMapping +}): TTenderlyVnetResponse | undefined { + const adminRpcMatch = params.currentMapping.adminRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Admin RPC') === params.currentMapping.adminRpcUri) + : undefined + + if (adminRpcMatch) { + return adminRpcMatch + } + + const publicRpcMatch = params.currentMapping.publicRpcUri + ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Public RPC') === params.currentMapping.publicRpcUri) + : undefined + + if (publicRpcMatch) { + return publicRpcMatch + } + + const executionChainMatches = + params.currentMapping.executionChainId === undefined + ? [] + : params.vnets.filter( + (vnet) => + Number(vnet.virtual_network_config?.chain_config?.chain_id) === params.currentMapping.executionChainId + ) + + return executionChainMatches.length === 1 ? executionChainMatches[0] : undefined +} + function selectPublicRpc(details: TTenderlyVnetConnectionDetails): string | undefined { return details.predictablePublicRpc || details.publicRpc } @@ -422,8 +656,12 @@ export function buildSanitizedVnetJson(params: { networkId: number response: TTenderlyVnetResponse details: TTenderlyVnetConnectionDetails + rpcName: string envFilePath?: string responseFilePath?: string + replacedVnetId?: string + deletedPreviousVnetId?: string + deletionNote?: string }): Record { const sanitizedResponse = Object.fromEntries( Object.entries(params.response).filter(([key]) => key !== 'rpcs') @@ -439,10 +677,14 @@ export function buildSanitizedVnetJson(params: { display_name: params.response.display_name || params.displayName, chain_id: params.chainId, network_id: params.networkId, + rpc_name: params.rpcName, has_admin_rpc: Boolean(params.details.adminRpc), has_public_rpc: Boolean(selectPublicRpc(params.details)), env_file_path: params.envFilePath || null, response_file_path: params.responseFilePath || null, + replaced_vnet_id: params.replacedVnetId || null, + deleted_previous_vnet_id: params.deletedPreviousVnetId || null, + deletion_note: params.deletionNote || null, note: hasSensitiveValues && !params.envFilePath && !params.responseFilePath ? 'Sensitive RPC values were returned but were not printed. Use --write-env or --write-response to persist them locally.' @@ -458,8 +700,12 @@ export function buildVnetConsoleSummary(params: { networkId: number response: TTenderlyVnetResponse details: TTenderlyVnetConnectionDetails + rpcName: string envFilePath?: string responseFilePath?: string + replacedVnetId?: string + deletedPreviousVnetId?: string + deletionNote?: string }): string[] { const hasSensitiveValues = Boolean( params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc @@ -470,11 +716,15 @@ export function buildVnetConsoleSummary(params: { `profile: ${params.profile}`, `slug: ${params.response.slug || params.requestedSlug}`, `display name: ${params.response.display_name || params.displayName}`, + `rpc name: ${params.rpcName}`, params.envFilePath ? `wrote env fragment: ${params.envFilePath}` : undefined, params.responseFilePath ? `wrote raw response json: ${params.responseFilePath}` : undefined, hasSensitiveValues && !params.envFilePath && !params.responseFilePath ? 'Sensitive RPC values were returned but not printed. Use --write-env or --write-response to persist them locally.' : undefined, + params.replacedVnetId ? `replaced previous vnet: ${params.replacedVnetId}` : undefined, + params.deletedPreviousVnetId ? `deleted previous vnet: ${params.deletedPreviousVnetId}` : undefined, + params.deletionNote ? `previous vnet deletion note: ${params.deletionNote}` : undefined, `chain-id: ${params.chainId}`, `network-id: ${params.networkId}` ].filter((line): line is string => Boolean(line)) @@ -529,6 +779,59 @@ async function createVirtualTestNet( return parsed } +async function listVirtualTestNets( + apiKey: string, + accountSlug: string, + projectSlug: string +): Promise { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Access-Key': apiKey + } + } + ) + + const responseBody = (await response.text()) || '[]' + if (!response.ok) { + throw new Error( + buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parseResponseBody(responseBody) }) + ) + } + + return normalizeVnetListResponse(responseBody) +} + +async function deleteVirtualTestNet( + apiKey: string, + accountSlug: string, + projectSlug: string, + vnetId: string +): Promise { + const response = await fetch( + `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets/${encodeURIComponent(vnetId)}`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'X-Access-Key': apiKey + } + } + ) + + if (response.ok || response.status === 404) { + return + } + + const responseBody = (await response.text()) || '{}' + throw new Error( + buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parseResponseBody(responseBody) }) + ) +} + async function main(): Promise { const parsedArgs = parseCliArgs(process.argv.slice(2)) const { flags } = parsedArgs @@ -542,16 +845,42 @@ async function main(): Promise { const envFromFile = readEnvFile(resolve(scriptDir, '../.env')) const env = { ...envFromFile, ...process.env } const { apiKey, accountSlug, projectSlug, profile } = resolveTenderlyCredentials(flags, env) - const rpcName = resolveRpcName(flags, env, profile) + const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID + const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) + const rpcName = await resolveRequiredRpcName({ + flags, + env, + profile, + networkId, + canPrompt: Boolean(process.stdin.isTTY && process.stderr.isTTY), + promptOwner: async (suggestedOwner: string, suggestedRpcName: string): Promise => { + const readline = createInterface({ + input: process.stdin, + output: process.stderr + }) + + try { + return await readline.question(`Tenderly RPC owner suffix [${suggestedOwner}] -> ${suggestedRpcName}: `) + } finally { + readline.close() + } + } + }) + const currentChainMapping = resolveCurrentChainMapping(env, networkId) + const currentVnetToReplace = currentChainMapping + ? selectMatchingConfiguredVnet({ + vnets: await listVirtualTestNets(apiKey, accountSlug, projectSlug), + currentMapping: currentChainMapping + }) + : undefined const timestamp = Date.now().toString() const requestedSlug = getArg(flags, 'slug') || `vnet-${timestamp}` const defaultDisplayNamePrefix = profile === 'personal' ? 'Personal VNet' : 'Webops VNet' const displayName = getArg(flags, 'display-name') || `${defaultDisplayNamePrefix} ${timestamp}` - const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID - const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) const blockNumber = getArg(flags, 'block-number') || 'latest' const enableSync = parseBooleanFlag(flags, 'enable-sync', false) const enableExplorer = parseBooleanFlag(flags, 'enable-explorer', false) + const keepPrevious = parseBooleanFlag(flags, 'keep-previous', false) const { envFilePath, responseFilePath } = resolveRequestedOutputPaths(flags) const payload: TCreateVnetPayload = { @@ -579,6 +908,16 @@ async function main(): Promise { const response = await createVirtualTestNet(apiKey, accountSlug, projectSlug, payload) const details = getConnectionDetails(response, accountSlug, projectSlug, rpcName) + let deletedPreviousVnetId: string | undefined + let deletionNote: string | undefined + if (!keepPrevious && currentVnetToReplace?.id && currentVnetToReplace.id !== response.id) { + try { + await deleteVirtualTestNet(apiKey, accountSlug, projectSlug, currentVnetToReplace.id) + deletedPreviousVnetId = currentVnetToReplace.id + } catch (error) { + deletionNote = error instanceof Error ? sanitizeConsoleText(error.message) : 'Failed to delete previous vnet' + } + } if (envFilePath) { writeOutputFile( envFilePath, @@ -604,8 +943,12 @@ async function main(): Promise { networkId, response, details, + rpcName, envFilePath, - responseFilePath + responseFilePath, + replacedVnetId: currentVnetToReplace?.id, + deletedPreviousVnetId, + deletionNote }), null, 2 @@ -622,8 +965,12 @@ async function main(): Promise { networkId, response, details, + rpcName, envFilePath, - responseFilePath + responseFilePath, + replacedVnetId: currentVnetToReplace?.id, + deletedPreviousVnetId, + deletionNote }).forEach((line) => { console.log(line) }) From 6a8be78d6eb3a6da0c85e0c64ee28b7bbc007b38 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:00:49 +0200 Subject: [PATCH 10/15] chore: rebase merge (#1152) --- .env.example | 10 - scripts/tenderly-vnet.test.ts | 138 +------ scripts/tenderly-vnet.ts | 371 +----------------- .../components/widget/TokenSelector.tsx | 35 +- .../components/widget/deposit/index.tsx | 3 + .../components/widget/yvUSD/YvUsdWithdraw.tsx | 83 +++- .../shared/hooks/useChainTimestamp.ts | 12 +- 7 files changed, 110 insertions(+), 542 deletions(-) diff --git a/.env.example b/.env.example index 000238c60..b364548b3 100644 --- a/.env.example +++ b/.env.example @@ -19,19 +19,9 @@ VITE_TENDERLY_RPC_URI_FOR_1= TENDERLY_ADMIN_RPC_URI_FOR_1= VITE_TENDERLY_EXPLORER_URI_FOR_1= -WEBOPS_TENDERLY_API_KEY= -WEBOPS_ACCOUNT_SLUG= -WEBOPS_PROJECT_SLUG= -# Optional owner suffix override for derived stable RPC names like yearn-fi-webops-vnet-10-ross. -# If unset, the bootstrap script falls back to TENDERLY_RPC_OWNER or the local shell user. -WEBOPS_TENDERLY_RPC_OWNER= -WEBOPS_TENDERLY_RPC_NAME= - PERSONAL_TENDERLY_API_KEY= PERSONAL_ACCOUNT_SLUG= PERSONAL_PROJECT_SLUG= -# Optional owner suffix override for derived stable RPC names. -PERSONAL_TENDERLY_RPC_OWNER= PERSONAL_TENDERLY_RPC_NAME= VITE_ALCHEMY_KEY= diff --git a/scripts/tenderly-vnet.test.ts b/scripts/tenderly-vnet.test.ts index ae4febcd8..faad488f3 100644 --- a/scripts/tenderly-vnet.test.ts +++ b/scripts/tenderly-vnet.test.ts @@ -7,14 +7,8 @@ import { buildTenderlyApiErrorMessage, buildTenderlyEnvFragment, buildVnetConsoleSummary, - normalizeTenderlyRpcNameComponent, resolveExplorerUriFromResponse, - resolveRequiredRpcName, - resolveRpcOwner, - resolveTenderlyCredentials, sanitizeConsoleText, - suggestTenderlyRpcName, - suggestTenderlyRpcOwner, validateWritableOutputPath, writeOutputFile } from './tenderly-vnet' @@ -37,130 +31,6 @@ const response = { } describe('tenderly-vnet console safety', () => { - it('prefers webops account and project slugs over legacy and personal env vars', () => { - expect( - resolveTenderlyCredentials( - { profile: 'webops' }, - { - WEBOPS_TENDERLY_API_KEY: 'webops-key', - WEBOPS_ACCOUNT_SLUG: 'webops-account', - WEBOPS_PROJECT_SLUG: 'webops-project', - TENDERLY_ACCOUNT_SLUG: 'legacy-account', - TENDERLY_PROJECT_SLUG: 'legacy-project', - PERSONAL_ACCOUNT_SLUG: 'personal-account', - PERSONAL_PROJECT_SLUG: 'personal-project' - } - ) - ).toMatchObject({ - apiKey: 'webops-key', - accountSlug: 'webops-account', - projectSlug: 'webops-project', - profile: 'webops' - }) - }) - - it('falls back to legacy webops slug env vars when WEBOPS slugs are unset', () => { - expect( - resolveTenderlyCredentials( - { profile: 'webops' }, - { - WEBOPS_TENDERLY_API_KEY: 'webops-key', - TENDERLY_ACCOUNT_SLUG: 'legacy-account', - TENDERLY_PROJECT_SLUG: 'legacy-project' - } - ) - ).toMatchObject({ - accountSlug: 'legacy-account', - projectSlug: 'legacy-project' - }) - }) - - it('suggests stable rpc names by profile', () => { - expect(suggestTenderlyRpcName('webops', { networkId: 10, owner: 'ross' })).toBe('yearn-fi-webops-vnet-10-ross') - expect(suggestTenderlyRpcName('personal', { networkId: 8453 })).toBe('yearn-fi-personal-vnet-8453') - }) - - it('normalizes owner suffixes and resolves profile-specific owner env vars', () => { - expect(normalizeTenderlyRpcNameComponent('Ross Yearn')).toBe('ross-yearn') - expect( - resolveRpcOwner( - { profile: 'webops' }, - { - WEBOPS_TENDERLY_RPC_OWNER: 'Ross' - }, - 'webops' - ) - ).toBe('ross') - expect(suggestTenderlyRpcOwner({ USER: 'Ross' })).toBe('ross') - }) - - it('requires a stable rpc owner in non-interactive webops mode when no explicit rpc name is configured', async () => { - await expect( - resolveRequiredRpcName({ - flags: {}, - env: {}, - profile: 'webops', - networkId: 10, - canPrompt: false - }) - ).rejects.toThrow('Missing Tenderly RPC owner') - }) - - it('derives stable rpc names from owner-aware defaults and prompts for webops owner when needed', async () => { - await expect( - resolveRequiredRpcName({ - flags: {}, - env: { - WEBOPS_TENDERLY_RPC_OWNER: 'Ross' - }, - profile: 'webops', - networkId: 10 - }) - ).resolves.toBe('yearn-fi-webops-vnet-10-ross') - - await expect( - resolveRequiredRpcName({ - flags: {}, - env: { - USER: 'Ross' - }, - profile: 'webops', - networkId: 10 - }) - ).resolves.toBe('yearn-fi-webops-vnet-10-ross') - - await expect( - resolveRequiredRpcName({ - flags: {}, - env: {}, - profile: 'webops', - networkId: 10, - canPrompt: true, - promptOwner: async () => '' - }) - ).resolves.toBe('yearn-fi-webops-vnet-10-yourname') - - await expect( - resolveRequiredRpcName({ - flags: {}, - env: {}, - profile: 'webops', - networkId: 10, - canPrompt: true, - promptOwner: async () => 'Ross' - }) - ).resolves.toBe('yearn-fi-webops-vnet-10-ross') - - await expect( - resolveRequiredRpcName({ - flags: {}, - env: {}, - profile: 'personal', - networkId: 8453 - }) - ).resolves.toBe('yearn-fi-personal-vnet-8453') - }) - it('redacts URLs in console text', () => { expect(sanitizeConsoleText('failed at https://admin.rpc/secret-path')).toBe('failed at [redacted-url]') }) @@ -198,8 +68,7 @@ describe('tenderly-vnet console safety', () => { chainId: 694201, networkId: 1, response, - details: connectionDetails, - rpcName: 'yearn-fi-personal-vnet-1' + details: connectionDetails }).join('\n') expect(summary).toContain('Created Tenderly Virtual TestNet') @@ -218,7 +87,6 @@ describe('tenderly-vnet console safety', () => { networkId: 1, response, details: connectionDetails, - rpcName: 'yearn-fi-webops-vnet-1-ross', envFilePath: '/tmp/tenderly-vnet.env', responseFilePath: '/tmp/tenderly-vnet.json' }).join('\n') @@ -237,15 +105,13 @@ describe('tenderly-vnet console safety', () => { chainId: 694201, networkId: 1, response, - details: connectionDetails, - rpcName: 'yearn-fi-personal-vnet-1' + details: connectionDetails }) const serialized = JSON.stringify(sanitized) expect(serialized).toContain('"has_admin_rpc":true') expect(serialized).toContain('"has_public_rpc":true') - expect(serialized).toContain('"rpc_name"') expect(serialized).not.toContain('"rpcs"') expect(serialized).not.toContain(connectionDetails.adminRpc) expect(serialized).not.toContain(connectionDetails.publicRpc) diff --git a/scripts/tenderly-vnet.ts b/scripts/tenderly-vnet.ts index 11fb85490..cb1b2132c 100644 --- a/scripts/tenderly-vnet.ts +++ b/scripts/tenderly-vnet.ts @@ -2,7 +2,6 @@ import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' -import { createInterface } from 'node:readline/promises' import { fileURLToPath } from 'node:url' type TParsedCliArgs = { @@ -19,32 +18,12 @@ type TResolvedTenderlyCredentials = { profile: TTenderlyProfile } -type TTenderlyProfileDefaults = { - apiKeyEnv: string - accountEnvKeys: string[] - projectEnvKeys: string[] - rpcNameEnv: string - rpcOwnerEnv: string -} - type TTenderlyVnetResponse = { slug?: string display_name?: string chain_id?: number network_id?: number id?: string - fork_config?: { - network_id?: number | string - block_number?: number | string - } - virtual_network_config?: { - chain_config?: { - chain_id?: number | string - } - } - explorer_page_config?: { - enabled?: boolean - } rpcs?: Array<{ name: string url: string @@ -59,13 +38,6 @@ type TTenderlyVnetConnectionDetails = { explorerUri?: string } -type TTenderlyCurrentChainMapping = { - canonicalChainId: number - executionChainId?: number - adminRpcUri?: string - publicRpcUri?: string -} - type TCreateVnetPayload = { slug: string display_name: string @@ -94,28 +66,8 @@ type TCreateVnetPayload = { const DEFAULT_ACCOUNT_SLUG = 'me' const DEFAULT_NETWORK_ID = 1 const DEFAULT_CHAIN_ID_PREFIX = '69420' -const DEFAULT_RPC_NAMES: Record = { - webops: 'yearn-fi-webops-vnet', - personal: 'yearn-fi-personal-vnet' -} const TENDERLY_API_URL = 'https://api.tenderly.co/api/v1/account' const REDACTED_URL = '[redacted-url]' -const TENDERLY_PROFILE_DEFAULTS: Record = { - personal: { - apiKeyEnv: 'PERSONAL_TENDERLY_API_KEY', - accountEnvKeys: ['PERSONAL_ACCOUNT_SLUG', 'ACCOUNT_SLUG'], - projectEnvKeys: ['PERSONAL_PROJECT_SLUG', 'PROJECT_SLUG'], - rpcNameEnv: 'PERSONAL_TENDERLY_RPC_NAME', - rpcOwnerEnv: 'PERSONAL_TENDERLY_RPC_OWNER' - }, - webops: { - apiKeyEnv: 'WEBOPS_TENDERLY_API_KEY', - accountEnvKeys: ['WEBOPS_ACCOUNT_SLUG', 'TENDERLY_ACCOUNT_SLUG'], - projectEnvKeys: ['WEBOPS_PROJECT_SLUG', 'TENDERLY_PROJECT_SLUG'], - rpcNameEnv: 'WEBOPS_TENDERLY_RPC_NAME', - rpcOwnerEnv: 'WEBOPS_TENDERLY_RPC_OWNER' - } -} const HELP_TEXT = `Tenderly Virtual TestNet bootstrap @@ -135,26 +87,20 @@ Options: --network-id Parent network id (default: 1) --block-number Fork block number or latest (default: latest) --chain-id Execution chain id (default: 69420) - --rpc-name Stable public RPC name override for predictable endpoint reuse - --rpc-owner Owner suffix used to derive the default stable public RPC name + --rpc-name Stable public RPC name for predictable endpoint reuse --enable-sync Enable state sync (default: false) --enable-explorer Enable public explorer (default: false) - --keep-previous Keep the currently configured VNet instead of deleting it after a successful replacement --json Print a sanitized JSON summary --write-env Write the generated Tenderly env fragment to a local file --write-response Write the raw Tenderly API response JSON to a local file --help Show this help text Profiles: - webops -> WEBOPS_TENDERLY_API_KEY, WEBOPS_ACCOUNT_SLUG, WEBOPS_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME, WEBOPS_TENDERLY_RPC_OWNER - legacy slug fallback: TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG - personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME, PERSONAL_TENDERLY_RPC_OWNER + webops -> WEBOPS_TENDERLY_API_KEY, TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG, WEBOPS_TENDERLY_RPC_NAME + personal -> PERSONAL_TENDERLY_API_KEY, PERSONAL_ACCOUNT_SLUG, PERSONAL_PROJECT_SLUG, PERSONAL_TENDERLY_RPC_NAME Sensitive RPC values are never printed to stdout. Use --write-env or --write-response when you need them locally. Explicit flags always win over profile defaults. -If no explicit stable RPC name is configured, the script derives one from profile + chain + owner. -For webops, owner resolution prefers WEBOPS_TENDERLY_RPC_OWNER, then TENDERLY_RPC_OWNER, then the local shell user. -If no owner can be inferred, the script prompts in interactive shells and otherwise fails with a suggested owner. ` function parseProfile(value: string | undefined): TTenderlyProfile { @@ -266,15 +212,11 @@ function getEnvValue(env: Record, key?: string): str return env[key]?.trim() } -function getFirstEnvValue(env: Record, keys: string[]): string | undefined { - return keys.map((key) => env[key]?.trim()).find(Boolean) -} - export function sanitizeConsoleText(value: string): string { return value.replace(/https?:\/\/\S+/gi, REDACTED_URL) } -export function resolveTenderlyCredentials( +function resolveTenderlyCredentials( flags: Record, env: Record ): TResolvedTenderlyCredentials { @@ -326,114 +268,13 @@ export function resolveTenderlyCredentials( } } -export function resolveRpcName( - flags: Record, - env: Record, - profile: TTenderlyProfile -): string | undefined { - const profileDefaults = TENDERLY_PROFILE_DEFAULTS[profile] - return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaults.rpcNameEnv) || env.TENDERLY_RPC_NAME?.trim() -} - -export function normalizeTenderlyRpcNameComponent(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, '') -} - -export function resolveRpcOwner( +function resolveRpcName( flags: Record, env: Record, profile: TTenderlyProfile ): string | undefined { - const profileDefaults = TENDERLY_PROFILE_DEFAULTS[profile] - const rawValue = - getArg(flags, 'rpc-owner') || getEnvValue(env, profileDefaults.rpcOwnerEnv) || env.TENDERLY_RPC_OWNER?.trim() - - if (!rawValue) { - return undefined - } - - const normalizedValue = normalizeTenderlyRpcNameComponent(rawValue) - return normalizedValue || undefined -} - -export function suggestTenderlyRpcOwner(env: Record): string | undefined { - const rawValue = getFirstEnvValue(env, ['TENDERLY_RPC_OWNER', 'USER', 'LOGNAME', 'USERNAME']) - if (!rawValue) { - return undefined - } - - const normalizedValue = normalizeTenderlyRpcNameComponent(rawValue) - return normalizedValue || undefined -} - -export function suggestTenderlyRpcName( - profile: TTenderlyProfile, - params?: { - networkId?: number - owner?: string - } -): string { - const suffixParts = [params?.networkId?.toString(), params?.owner].filter(Boolean) - return [DEFAULT_RPC_NAMES[profile], ...suffixParts].join('-') -} - -export async function resolveRequiredRpcName(params: { - flags: Record - env: Record - profile: TTenderlyProfile - networkId: number - canPrompt?: boolean - promptOwner?: (suggestedOwner: string, suggestedRpcName: string) => Promise -}): Promise { - const configuredRpcName = resolveRpcName(params.flags, params.env, params.profile) - if (configuredRpcName) { - return configuredRpcName - } - - const configuredOwner = resolveRpcOwner(params.flags, params.env, params.profile) - if (configuredOwner) { - return suggestTenderlyRpcName(params.profile, { - networkId: params.networkId, - owner: configuredOwner - }) - } - - if (params.profile !== 'webops') { - return suggestTenderlyRpcName(params.profile, { networkId: params.networkId }) - } - - const suggestedOwner = suggestTenderlyRpcOwner(params.env) - if (suggestedOwner) { - return suggestTenderlyRpcName(params.profile, { - networkId: params.networkId, - owner: suggestedOwner - }) - } - - const fallbackSuggestedOwner = 'yourname' - const suggestedRpcName = suggestTenderlyRpcName(params.profile, { - networkId: params.networkId, - owner: fallbackSuggestedOwner - }) - - if (params.canPrompt && params.promptOwner) { - const promptedValue = normalizeTenderlyRpcNameComponent( - await params.promptOwner(fallbackSuggestedOwner, suggestedRpcName) - ) - return suggestTenderlyRpcName(params.profile, { - networkId: params.networkId, - owner: promptedValue || fallbackSuggestedOwner - }) - } - - throw new Error( - `Missing Tenderly RPC owner. Set WEBOPS_TENDERLY_RPC_OWNER, pass --rpc-owner, or use the suggested value: ${fallbackSuggestedOwner}. The derived stable RPC name would be: ${suggestedRpcName}` - ) + const profileDefaultEnvKey = profile === 'personal' ? 'PERSONAL_TENDERLY_RPC_NAME' : 'WEBOPS_TENDERLY_RPC_NAME' + return getArg(flags, 'rpc-name') || getEnvValue(env, profileDefaultEnvKey) || env.TENDERLY_RPC_NAME?.trim() } export function buildPredictablePublicRpcUrl(accountSlug: string, projectSlug: string, rpcName: string): string { @@ -496,81 +337,6 @@ function getConnectionDetails( } } -function resolveVnetRpcUrl(vnet: TTenderlyVnetResponse, rpcName: string): string | undefined { - return vnet.rpcs?.find((rpc) => rpc.name === rpcName)?.url -} - -function normalizeVnetListResponse(responseBody: string): TTenderlyVnetResponse[] { - const parsed = parseResponseBody(responseBody) as unknown - - if (Array.isArray(parsed)) { - return parsed.filter((entry): entry is TTenderlyVnetResponse => isRecord(entry)) - } - - if (isRecord(parsed)) { - const nestedList = [parsed.vnets, parsed.virtual_networks, parsed.data].find(Array.isArray) - if (nestedList) { - return nestedList.filter((entry): entry is TTenderlyVnetResponse => isRecord(entry)) - } - } - - return [] -} - -function resolveCurrentChainMapping( - env: Record, - canonicalChainId: number -): TTenderlyCurrentChainMapping | undefined { - const executionChainId = parseOptionalInteger( - getEnvValue(env, `VITE_TENDERLY_CHAIN_ID_FOR_${canonicalChainId}`), - `VITE_TENDERLY_CHAIN_ID_FOR_${canonicalChainId}` - ) - const adminRpcUri = getEnvValue(env, `TENDERLY_ADMIN_RPC_URI_FOR_${canonicalChainId}`) - const publicRpcUri = getEnvValue(env, `VITE_TENDERLY_RPC_URI_FOR_${canonicalChainId}`) - - if (!executionChainId && !adminRpcUri && !publicRpcUri) { - return undefined - } - - return { - canonicalChainId, - executionChainId, - adminRpcUri, - publicRpcUri - } -} - -function selectMatchingConfiguredVnet(params: { - vnets: TTenderlyVnetResponse[] - currentMapping: TTenderlyCurrentChainMapping -}): TTenderlyVnetResponse | undefined { - const adminRpcMatch = params.currentMapping.adminRpcUri - ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Admin RPC') === params.currentMapping.adminRpcUri) - : undefined - - if (adminRpcMatch) { - return adminRpcMatch - } - - const publicRpcMatch = params.currentMapping.publicRpcUri - ? params.vnets.find((vnet) => resolveVnetRpcUrl(vnet, 'Public RPC') === params.currentMapping.publicRpcUri) - : undefined - - if (publicRpcMatch) { - return publicRpcMatch - } - - const executionChainMatches = - params.currentMapping.executionChainId === undefined - ? [] - : params.vnets.filter( - (vnet) => - Number(vnet.virtual_network_config?.chain_config?.chain_id) === params.currentMapping.executionChainId - ) - - return executionChainMatches.length === 1 ? executionChainMatches[0] : undefined -} - function selectPublicRpc(details: TTenderlyVnetConnectionDetails): string | undefined { return details.predictablePublicRpc || details.publicRpc } @@ -656,12 +422,8 @@ export function buildSanitizedVnetJson(params: { networkId: number response: TTenderlyVnetResponse details: TTenderlyVnetConnectionDetails - rpcName: string envFilePath?: string responseFilePath?: string - replacedVnetId?: string - deletedPreviousVnetId?: string - deletionNote?: string }): Record { const sanitizedResponse = Object.fromEntries( Object.entries(params.response).filter(([key]) => key !== 'rpcs') @@ -677,14 +439,10 @@ export function buildSanitizedVnetJson(params: { display_name: params.response.display_name || params.displayName, chain_id: params.chainId, network_id: params.networkId, - rpc_name: params.rpcName, has_admin_rpc: Boolean(params.details.adminRpc), has_public_rpc: Boolean(selectPublicRpc(params.details)), env_file_path: params.envFilePath || null, response_file_path: params.responseFilePath || null, - replaced_vnet_id: params.replacedVnetId || null, - deleted_previous_vnet_id: params.deletedPreviousVnetId || null, - deletion_note: params.deletionNote || null, note: hasSensitiveValues && !params.envFilePath && !params.responseFilePath ? 'Sensitive RPC values were returned but were not printed. Use --write-env or --write-response to persist them locally.' @@ -700,12 +458,8 @@ export function buildVnetConsoleSummary(params: { networkId: number response: TTenderlyVnetResponse details: TTenderlyVnetConnectionDetails - rpcName: string envFilePath?: string responseFilePath?: string - replacedVnetId?: string - deletedPreviousVnetId?: string - deletionNote?: string }): string[] { const hasSensitiveValues = Boolean( params.details.adminRpc || params.details.publicRpc || params.details.predictablePublicRpc @@ -716,15 +470,11 @@ export function buildVnetConsoleSummary(params: { `profile: ${params.profile}`, `slug: ${params.response.slug || params.requestedSlug}`, `display name: ${params.response.display_name || params.displayName}`, - `rpc name: ${params.rpcName}`, params.envFilePath ? `wrote env fragment: ${params.envFilePath}` : undefined, params.responseFilePath ? `wrote raw response json: ${params.responseFilePath}` : undefined, hasSensitiveValues && !params.envFilePath && !params.responseFilePath ? 'Sensitive RPC values were returned but not printed. Use --write-env or --write-response to persist them locally.' : undefined, - params.replacedVnetId ? `replaced previous vnet: ${params.replacedVnetId}` : undefined, - params.deletedPreviousVnetId ? `deleted previous vnet: ${params.deletedPreviousVnetId}` : undefined, - params.deletionNote ? `previous vnet deletion note: ${params.deletionNote}` : undefined, `chain-id: ${params.chainId}`, `network-id: ${params.networkId}` ].filter((line): line is string => Boolean(line)) @@ -779,59 +529,6 @@ async function createVirtualTestNet( return parsed } -async function listVirtualTestNets( - apiKey: string, - accountSlug: string, - projectSlug: string -): Promise { - const response = await fetch( - `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets`, - { - method: 'GET', - headers: { - Accept: 'application/json', - 'X-Access-Key': apiKey - } - } - ) - - const responseBody = (await response.text()) || '[]' - if (!response.ok) { - throw new Error( - buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parseResponseBody(responseBody) }) - ) - } - - return normalizeVnetListResponse(responseBody) -} - -async function deleteVirtualTestNet( - apiKey: string, - accountSlug: string, - projectSlug: string, - vnetId: string -): Promise { - const response = await fetch( - `${TENDERLY_API_URL}/${encodeURIComponent(accountSlug)}/project/${encodeURIComponent(projectSlug)}/vnets/${encodeURIComponent(vnetId)}`, - { - method: 'DELETE', - headers: { - Accept: 'application/json', - 'X-Access-Key': apiKey - } - } - ) - - if (response.ok || response.status === 404) { - return - } - - const responseBody = (await response.text()) || '{}' - throw new Error( - buildTenderlyApiErrorMessage({ status: response.status, parsedBody: parseResponseBody(responseBody) }) - ) -} - async function main(): Promise { const parsedArgs = parseCliArgs(process.argv.slice(2)) const { flags } = parsedArgs @@ -845,42 +542,16 @@ async function main(): Promise { const envFromFile = readEnvFile(resolve(scriptDir, '../.env')) const env = { ...envFromFile, ...process.env } const { apiKey, accountSlug, projectSlug, profile } = resolveTenderlyCredentials(flags, env) - const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID - const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) - const rpcName = await resolveRequiredRpcName({ - flags, - env, - profile, - networkId, - canPrompt: Boolean(process.stdin.isTTY && process.stderr.isTTY), - promptOwner: async (suggestedOwner: string, suggestedRpcName: string): Promise => { - const readline = createInterface({ - input: process.stdin, - output: process.stderr - }) - - try { - return await readline.question(`Tenderly RPC owner suffix [${suggestedOwner}] -> ${suggestedRpcName}: `) - } finally { - readline.close() - } - } - }) - const currentChainMapping = resolveCurrentChainMapping(env, networkId) - const currentVnetToReplace = currentChainMapping - ? selectMatchingConfiguredVnet({ - vnets: await listVirtualTestNets(apiKey, accountSlug, projectSlug), - currentMapping: currentChainMapping - }) - : undefined + const rpcName = resolveRpcName(flags, env, profile) const timestamp = Date.now().toString() const requestedSlug = getArg(flags, 'slug') || `vnet-${timestamp}` const defaultDisplayNamePrefix = profile === 'personal' ? 'Personal VNet' : 'Webops VNet' const displayName = getArg(flags, 'display-name') || `${defaultDisplayNamePrefix} ${timestamp}` + const networkId = parseOptionalInteger(getArg(flags, 'network-id'), 'network-id') || DEFAULT_NETWORK_ID + const chainId = parseOptionalInteger(getArg(flags, 'chain-id'), 'chain-id') || resolveDefaultChainId(networkId) const blockNumber = getArg(flags, 'block-number') || 'latest' const enableSync = parseBooleanFlag(flags, 'enable-sync', false) const enableExplorer = parseBooleanFlag(flags, 'enable-explorer', false) - const keepPrevious = parseBooleanFlag(flags, 'keep-previous', false) const { envFilePath, responseFilePath } = resolveRequestedOutputPaths(flags) const payload: TCreateVnetPayload = { @@ -908,16 +579,6 @@ async function main(): Promise { const response = await createVirtualTestNet(apiKey, accountSlug, projectSlug, payload) const details = getConnectionDetails(response, accountSlug, projectSlug, rpcName) - let deletedPreviousVnetId: string | undefined - let deletionNote: string | undefined - if (!keepPrevious && currentVnetToReplace?.id && currentVnetToReplace.id !== response.id) { - try { - await deleteVirtualTestNet(apiKey, accountSlug, projectSlug, currentVnetToReplace.id) - deletedPreviousVnetId = currentVnetToReplace.id - } catch (error) { - deletionNote = error instanceof Error ? sanitizeConsoleText(error.message) : 'Failed to delete previous vnet' - } - } if (envFilePath) { writeOutputFile( envFilePath, @@ -943,12 +604,8 @@ async function main(): Promise { networkId, response, details, - rpcName, envFilePath, - responseFilePath, - replacedVnetId: currentVnetToReplace?.id, - deletedPreviousVnetId, - deletionNote + responseFilePath }), null, 2 @@ -965,12 +622,8 @@ async function main(): Promise { networkId, response, details, - rpcName, envFilePath, - responseFilePath, - replacedVnetId: currentVnetToReplace?.id, - deletedPreviousVnetId, - deletionNote + responseFilePath }).forEach((line) => { console.log(line) }) diff --git a/src/components/pages/vaults/components/widget/TokenSelector.tsx b/src/components/pages/vaults/components/widget/TokenSelector.tsx index c67db93e6..af7fb1a1a 100644 --- a/src/components/pages/vaults/components/widget/TokenSelector.tsx +++ b/src/components/pages/vaults/components/widget/TokenSelector.tsx @@ -10,7 +10,7 @@ import { useWallet } from '@shared/contexts/useWallet' import { useYearn } from '@shared/contexts/useYearn' import { useTokenList } from '@shared/contexts/WithTokenList' import type { TToken } from '@shared/types' -import { cl, formatTAmount, toAddress } from '@shared/utils' +import { cl, formatTAmount, isZeroAddress, toAddress } from '@shared/utils' import { type FC, useCallback, useMemo, useState } from 'react' import { isAddress } from 'viem' import { CloseIcon } from './shared/Icons' @@ -142,21 +142,11 @@ export const TokenSelector: FC = ({ const [searchText, setSearchText] = useState('') const [selectedChainId, setSelectedChainId] = useState(chainId) const { getToken, isLoading, balances } = useWallet() - - // Derived: treat valid address input as custom token - const customAddress = searchText && isAddress(searchText) ? (searchText as `0x${string}`) : undefined - - // Available chains - you can expand this list as needed - const availableChains = useMemo( - () => [ - { id: 1, name: 'Ethereum' }, - { id: 10, name: 'Optimism' }, - { id: 137, name: 'Polygon' }, - { id: 42161, name: 'Arbitrum' }, - { id: 8453, name: 'Base' }, - { id: 747474, name: 'Katana' } - ], - [] + const { tokenLists } = useTokenList() + const { allVaults, getPrice } = useYearn() + const customAddress = useMemo( + () => (searchText && isAddress(searchText) ? (searchText as `0x${string}`) : undefined), + [searchText] ) const priorityTokenAddresses = useMemo( @@ -378,7 +368,18 @@ export const TokenSelector: FC = ({ topTokenAddresses, getTokenUsdValue }) - }, [tokens, limitTokens, excludeTokens, searchText]) + }, [ + tokens, + mode, + limitTokens, + combinedExcludeTokens, + searchText, + yearnKnownTokenAddresses, + explicitTokenAddresses, + minValueExemptTokenAddresses, + topTokenAddresses, + getTokenUsdValue + ]) const handleSelect = useCallback( (address: `0x${string}`) => { diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index 11d484e4a..9e5dd643e 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -2,6 +2,7 @@ import { InputTokenAmount } from '@pages/vaults/components/widget/InputTokenAmou import { useDebouncedInput } from '@pages/vaults/hooks/useDebouncedInput' import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { Button } from '@shared/components/Button' +import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' import { IconSettings } from '@shared/icons/IconSettings' @@ -22,6 +23,7 @@ import { useWidgetContext } from '../shared/useWidgetContext' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' import { WidgetLoadingSkeleton } from '../shared/WidgetLoadingSkeleton' +import { DEPOSIT_COMMON_TOKENS_BY_CHAIN } from '../withdraw/constants' import { AnnualReturnOverlay } from './AnnualReturnOverlay' import { ApprovalOverlay } from './ApprovalOverlay' import { getDepositApprovalSpender } from './approvalSpender' @@ -157,6 +159,7 @@ export function WidgetDeposit({ trackEvent, ensoEnabled } = useWidgetContext({ chainId, vaultAddress }) + const { allVaults } = useYearn() const [showVaultSharesModal, setShowVaultSharesModal] = useState(false) const [showVaultShareValueModal, setShowVaultShareValueModal] = useState(false) diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx index 5d6a683ef..35f6999a7 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx @@ -142,8 +142,13 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const [pendingPrefillAmount, setPendingPrefillAmount] = useState(undefined) const [pendingPrefillAddress, setPendingPrefillAddress] = useState<`0x${string}` | undefined>(undefined) const [prefillRequestKey, setPrefillRequestKey] = useState(0) - const [lockedRequestedAmountRaw, setLockedRequestedAmountRaw] = useState(0n) - const [selectedWithdrawTokenAddress, setSelectedWithdrawTokenAddress] = useState<`0x${string}` | undefined>(undefined) + const [lockedWithdrawPhase, setLockedWithdrawPhase] = useState<'withdraw' | 'redeem'>('withdraw') + const [lockedWithdrawExecutionSnapshot, setLockedWithdrawExecutionSnapshot] = + useState(null) + const [selectedLockedWithdrawTokenAddress, setSelectedLockedWithdrawTokenAddress] = useState< + `0x${string}` | undefined + >(undefined) + const [selectedLockedWithdrawChainId, setSelectedLockedWithdrawChainId] = useState(undefined) const activeVariant = variant ?? 'unlocked' const isLockedVariant = activeVariant === 'locked' @@ -259,7 +264,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap nowTimestamp, cooldownEnd: cooldownStatus.cooldownEnd, windowEnd: cooldownStatus.windowEnd, - availableWithdrawLimit + availableWithdrawLimit: maxWithdrawAssets }) const needsCooldownStart = hasLocked && (!hasActiveCooldown || isCooldownWindowExpired) @@ -816,7 +821,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const lockedWithdrawArgs = lockedWithdrawExecutionPlan[0]?.args const unlockedWithdrawArgs = lockedWithdrawExecutionPlan[1]?.args - const prepareLockedRedeemNow: UseSimulateContractReturnType = useSimulateContract({ + const prepareLockedRedeemNow: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: erc4626Abi, functionName: 'redeem', @@ -833,7 +838,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap executionLockedWithdrawShares > 0n } }) - const prepareLockedWithdrawNow: UseSimulateContractReturnType = useSimulateContract({ + const prepareLockedWithdrawNow: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_LOCKED_ADDRESS, abi: erc4626Abi, functionName: 'withdraw', @@ -853,7 +858,7 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const prepareLockedWithdrawStep = executionLockedWithdrawMethod === 'redeem' ? prepareLockedRedeemNow : prepareLockedWithdrawNow - const prepareUnlockedWithdraw: UseSimulateContractReturnType = useSimulateContract({ + const prepareUnlockedWithdraw: AppUseSimulateContractReturnType = useSimulateContract({ address: YVUSD_UNLOCKED_ADDRESS, abi: erc4626Abi, functionName: 'withdraw', @@ -1025,19 +1030,61 @@ export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collap const selectedVault = isLockedVariant ? lockedVault : unlockedVault const selectedRouteAssetAddress = isLockedVariant ? lockedAssetAddress : unlockedAssetAddress const selectedVaultUserData = isLockedVariant ? lockedDisplayUserData : unlockedUserData + const usesLockedManagedWithdrawFlow = + isLockedVariant && + !!account && + shouldUseLockedManagedWithdrawFlow({ + canWithdrawNow, + selectedTokenAddress: selectedLockedWithdrawTokenAddress, + selectedChainId: selectedLockedWithdrawChainId, + chainId, + underlyingAssetAddress: unlockedAssetAddress + }) const disableLockedAmountInput = isLockedVariant && isCooldownActive && !canWithdrawNow - const hideLockedWithdrawAction = isLockedVariant && !!account && !canWithdrawNow - const effectiveLockedActionDisabledReason = - isLockedVariant && !hideLockedWithdrawAction ? lockedActionDisabledReason : undefined - const lockedRequestedShares = clampLockedRequestedShares( - lockedRequestedAmountRaw, - canWithdrawNow, - availableWithdrawSharesCap - ) - const lockedExpectedUnderlyingOut = - lockedDisplayPricePerShare > 0n - ? (lockedRequestedShares * lockedDisplayPricePerShare) / 10n ** BigInt(lockedVaultTokenDecimals) - : 0n + const hideLockedWithdrawAction = usesLockedManagedWithdrawFlow + const lockedWithdrawInputError = + !isLockedVariant || !account || draftWithdrawAmount <= 0n + ? undefined + : !canWithdrawNow + ? maxCooldownAssetAmount > 0n && lockedRequestedCooldownAssets > maxCooldownAssetAmount + ? 'Insufficient balance' + : undefined + : !shouldNormalizeLockedWithdrawMaxInput && + lockedMaxWithdrawDisplayAmount > 0n && + draftWithdrawAmount > lockedMaxWithdrawDisplayAmount + ? 'Amount exceeds currently available withdraw limit.' + : undefined + const lockedReadyWithdrawStep = + !account || + executionLockedWithdrawAssets <= 0n || + (lockedWithdrawPhase === 'withdraw' && !canWithdrawNow) || + (lockedWithdrawPhase === 'redeem' && !hasLockedWithdrawExecutionSnapshot) || + executionUnderlyingWithdrawAssets <= 0n + ? undefined + : buildLockedWithdrawTransactionStep({ + phase: lockedWithdrawPhase, + lockedStepMethod: executionLockedWithdrawMethod, + prepareLockedWithdraw: prepareLockedWithdrawStep, + prepareUnlockedWithdraw: prepareUnlockedWithdraw, + requestedLockedShares: executionLockedWithdrawShares, + receivedLockedAssets: executionLockedWithdrawAssets, + expectedUnderlyingOut: executionExpectedUnderlyingOut, + lockedVaultTokenDecimals, + lockedAssetDecimals, + underlyingDecimals: unlockedAssetDecimals, + lockedVaultTokenSymbol, + lockedAssetSymbol: lockedUserData.assetToken?.symbol ?? 'yvUSD', + underlyingSymbol: unlockedUserData.assetToken?.symbol ?? 'USDC' + }) + const isLockedWithdrawReady = + draftWithdrawAmount > 0n && + (currentLockedWithdrawMethod === 'redeem' + ? lockedRequestedWithdrawShares > 0n && lockedRequestedWithdrawShares <= maxRedeemShares + : lockedRequestedWithdrawAssets > 0n && lockedRequestedWithdrawAssets <= maxWithdrawAssets) && + executionUnderlyingWithdrawAssets > 0n && + !lockedWithdrawInputError && + !!lockedReadyWithdrawStep?.prepare.isSuccess && + !!lockedReadyWithdrawStep.prepare.data?.request const withdrawPrefill = getWithdrawPrefill( activeVariant, diff --git a/src/components/shared/hooks/useChainTimestamp.ts b/src/components/shared/hooks/useChainTimestamp.ts index 40a89b0a9..7ee9a0014 100644 --- a/src/components/shared/hooks/useChainTimestamp.ts +++ b/src/components/shared/hooks/useChainTimestamp.ts @@ -1,5 +1,5 @@ import { useBlockNumber, usePublicClient } from '@shared/hooks/useAppWagmi' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' type TChainTimestampState = { blockTimestamp: number @@ -21,22 +21,30 @@ export function useChainTimestamp({ chainId, enabled = true }: { chainId?: numbe const [chainTimestampState, setChainTimestampState] = useState(undefined) const [isLoading, setIsLoading] = useState(false) const [, setTick] = useState(0) + const latestFetchIdRef = useRef(0) const refetch = useCallback(async (): Promise => { if (!enabled || !publicClient) { return } + const fetchId = latestFetchIdRef.current + 1 + latestFetchIdRef.current = fetchId setIsLoading(true) try { const block = blockNumber !== undefined ? await publicClient.getBlock({ blockNumber }) : await publicClient.getBlock() + if (fetchId !== latestFetchIdRef.current) { + return + } setChainTimestampState({ blockTimestamp: Number(block.timestamp), fetchedAtMs: Date.now() }) } finally { - setIsLoading(false) + if (fetchId === latestFetchIdRef.current) { + setIsLoading(false) + } } }, [blockNumber, enabled, publicClient]) From a9938f1162e6729d4598db48a157a3ddfc1624f8 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:20:45 +0100 Subject: [PATCH 11/15] chore: remove tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEEP (12 files) — real math/calculations: 1. strategiesPercentFormat.test.ts — percent rounding rules 2. format.test.ts — locale-aware currency formatting 3. vaultApy.test.ts — APY/APR compound interest math 4. useVaultApyData.test.ts — Katana APR computation 5. valuation.test.ts — deposit value, price impact % 6. cooldownUtils.test.ts — duration/time math 7. yvUsd.test.ts — weighted APY, PPS conversions, APR annualization, bigint math 8. useYvUsdCharts.helpers.test.ts — PPS series, APY derivation 9. YvUsdWithdraw.helpers.test.ts — share/asset bigint conversions 10. useYvUsdVaults.helpers.test.ts — TVL breakdown math 11. kongVaultSelectors.test.ts — pricePerShare conversion, APY selection 12. useVaultFilterUtils.test.ts — USD valuation from pricePerShare --- CLAUDE.md | 6 + src/components/Link.test.tsx | 43 -- .../hooks/buildVaultSuggestions.test.ts | 138 ---- .../portfolio/hooks/getEligibleVaults.test.ts | 85 --- .../hooks/portfolioVisibility.test.ts | 107 --- .../components/SuggestedVaultCard.test.tsx | 105 --- .../components/detail/QuickStatsGrid.test.tsx | 107 --- .../detail/VaultInfoSection.test.ts | 112 --- .../components/detail/VaultsListHead.test.ts | 12 - .../detail/VaultsListStrategy.test.ts | 15 - .../detail/desktopWidgetSizing.test.ts | 33 - .../filters/VaultVersionToggle.test.tsx | 37 - .../components/list/VaultsListEmpty.test.tsx | 55 -- .../components/list/VaultsListRow.test.tsx | 385 ----------- .../components/table/APYSubline.test.ts | 69 -- .../components/table/VaultForwardAPY.test.tsx | 57 -- .../table/apyDisplayConfig.test.tsx | 56 -- .../widget/InputTokenAmount.test.tsx | 93 --- .../components/widget/TokenSelector.test.tsx | 649 ------------------ .../widget/deposit/approvalSpender.test.ts | 32 - .../deposit/tokenSelectorFiltering.test.ts | 105 --- .../widget/deposit/useDepositRoute.test.ts | 62 -- .../widget/shared/TransactionOverlay.test.ts | 98 --- .../widget/tokenSelectorList.utils.test.ts | 255 ------- .../widget/withdraw/useWithdrawRoute.test.ts | 64 -- .../withdraw/withdrawStepHelpers.test.ts | 181 ----- .../widget/yvUSD/YvUsdDeposit.test.tsx | 36 - .../components/yvUSD/YvUsdBreakdown.test.tsx | 11 - .../yvUSD/YvUsdHeaderBanner.test.tsx | 16 - .../vaults/domain/normalizeVault.test.ts | 19 - .../pages/vaults/domain/vaultWarnings.test.ts | 66 -- .../hooks/actions/stakingAdapter.test.ts | 204 ------ .../hooks/useEnsureVaultListFetch.test.ts | 49 -- .../utils/blockingFilterInsights.test.ts | 64 -- .../pages/vaults/utils/chainSelection.test.ts | 18 - .../pages/vaults/utils/vaultLogo.test.ts | 39 -- .../shared/components/Tooltip.test.tsx | 76 -- .../shared/components/YearnApps.test.ts | 21 - .../shared/contexts/useChartStyle.test.ts | 22 - .../contexts/useNotifications.helpers.test.ts | 65 -- .../shared/contexts/useWallet.helpers.test.ts | 74 -- .../shared/hooks/useBalancesCombined.test.ts | 40 -- .../shared/hooks/useBalancesQueries.test.ts | 171 ----- .../shared/hooks/useBalancesQuery.test.ts | 30 - .../shared/hooks/useBalancesRouting.test.ts | 142 ---- .../shared/hooks/useFetchYearnVaults.test.ts | 37 - .../shared/utils/curveUrlUtils.test.ts | 44 -- src/components/shared/utils/routes.test.ts | 23 - .../schemas/kongVaultSnapshotSchema.test.ts | 37 - .../shared/utils/tenderlyPanel.test.ts | 333 --------- .../shared/utils/wagmi/utils.test.ts | 23 - src/config/supportedChains.test.ts | 22 - src/config/tenderly.test.ts | 119 ---- src/config/wagmiChains.test.ts | 47 -- src/config/wagmiTransports.test.ts | 37 - 55 files changed, 6 insertions(+), 4840 deletions(-) delete mode 100644 src/components/Link.test.tsx delete mode 100644 src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts delete mode 100644 src/components/pages/portfolio/hooks/getEligibleVaults.test.ts delete mode 100644 src/components/pages/portfolio/hooks/portfolioVisibility.test.ts delete mode 100644 src/components/pages/vaults/components/SuggestedVaultCard.test.tsx delete mode 100644 src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx delete mode 100644 src/components/pages/vaults/components/detail/VaultInfoSection.test.ts delete mode 100644 src/components/pages/vaults/components/detail/VaultsListHead.test.ts delete mode 100644 src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts delete mode 100644 src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts delete mode 100644 src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx delete mode 100644 src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx delete mode 100644 src/components/pages/vaults/components/list/VaultsListRow.test.tsx delete mode 100644 src/components/pages/vaults/components/table/APYSubline.test.ts delete mode 100644 src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx delete mode 100644 src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx delete mode 100644 src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx delete mode 100644 src/components/pages/vaults/components/widget/TokenSelector.test.tsx delete mode 100644 src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts delete mode 100644 src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts delete mode 100644 src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts delete mode 100644 src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts delete mode 100644 src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts delete mode 100644 src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts delete mode 100644 src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts delete mode 100644 src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx delete mode 100644 src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx delete mode 100644 src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx delete mode 100644 src/components/pages/vaults/domain/normalizeVault.test.ts delete mode 100644 src/components/pages/vaults/domain/vaultWarnings.test.ts delete mode 100644 src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts delete mode 100644 src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts delete mode 100644 src/components/pages/vaults/utils/blockingFilterInsights.test.ts delete mode 100644 src/components/pages/vaults/utils/chainSelection.test.ts delete mode 100644 src/components/pages/vaults/utils/vaultLogo.test.ts delete mode 100644 src/components/shared/components/Tooltip.test.tsx delete mode 100644 src/components/shared/components/YearnApps.test.ts delete mode 100644 src/components/shared/contexts/useChartStyle.test.ts delete mode 100644 src/components/shared/contexts/useNotifications.helpers.test.ts delete mode 100644 src/components/shared/contexts/useWallet.helpers.test.ts delete mode 100644 src/components/shared/hooks/useBalancesCombined.test.ts delete mode 100644 src/components/shared/hooks/useBalancesQueries.test.ts delete mode 100644 src/components/shared/hooks/useBalancesQuery.test.ts delete mode 100644 src/components/shared/hooks/useBalancesRouting.test.ts delete mode 100644 src/components/shared/hooks/useFetchYearnVaults.test.ts delete mode 100644 src/components/shared/utils/curveUrlUtils.test.ts delete mode 100644 src/components/shared/utils/routes.test.ts delete mode 100644 src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts delete mode 100644 src/components/shared/utils/tenderlyPanel.test.ts delete mode 100644 src/components/shared/utils/wagmi/utils.test.ts delete mode 100644 src/config/supportedChains.test.ts delete mode 100644 src/config/tenderly.test.ts delete mode 100644 src/config/wagmiChains.test.ts delete mode 100644 src/config/wagmiTransports.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 195593384..b78d201b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,12 @@ IMPORTANT: After making code changes, always verify: Husky runs `lint-staged` + `bun run tslint` on every commit. +## Testing + +- NEVER create UI/component tests (tests that use `render`, `screen`, `@testing-library/react`, or similar) +- Only create tests for math/calculation logic (APY/APR, price impact, share/asset conversions, formatting numbers, duration math) +- Do NOT create tests for: config assertions, boolean flag logic, string/URL mapping, route resolution, schema validation, CSS classes, array/set manipulation, state machines, or ABI construction + ## Code Style Formatting is enforced by Biome (biome.jsonc) — do not worry about indentation, quotes, or commas. diff --git a/src/components/Link.test.tsx b/src/components/Link.test.tsx deleted file mode 100644 index 997cb332b..000000000 --- a/src/components/Link.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' -import Link from './Link' - -describe('Link', () => { - it('renders external links as with safe defaults', () => { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - const html = renderToStaticMarkup(Example) - - expect(html).toContain('href="https://example.com"') - expect(html).toContain('target="_blank"') - expect(html).toContain('rel="noopener noreferrer"') - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } - }) - - it('renders internal links through react-router', () => { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - const html = renderToStaticMarkup( - - Vaults - - ) - - expect(html).toContain('href="/vaults"') - expect(html).not.toContain('target="_blank"') - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } - }) -}) diff --git a/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts b/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts deleted file mode 100644 index 43e738642..000000000 --- a/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { TExternalToken } from '@pages/portfolio/constants/externalTokens' -import { buildVaultSuggestions } from '@pages/portfolio/hooks/buildVaultSuggestions' -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' - -const buildVault = ({ - address, - assetSymbol, - tvl, - apr -}: { - address: string - assetSymbol: string - tvl: number - apr: number -}): TKongVault => - ({ - chainId: 1, - address, - name: `${assetSymbol} Vault`, - symbol: `yv${assetSymbol}`, - apiVersion: '3.0.0', - decimals: 18, - asset: { - address: - assetSymbol === 'USDC' - ? '0x0000000000000000000000000000000000000010' - : assetSymbol === 'DAI' - ? '0x0000000000000000000000000000000000000011' - : '0x0000000000000000000000000000000000000012', - name: assetSymbol, - symbol: assetSymbol, - decimals: 18 - }, - tvl, - performance: { - oracle: { apr, apy: apr }, - estimated: { - apr, - apy: apr, - type: 'estimated', - components: {} - }, - historical: { - net: apr, - weeklyNet: apr, - monthlyNet: apr, - inceptionNet: apr - } - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Multi Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: true, - strategiesCount: 1, - riskLevel: 1, - staking: { - address: null, - available: false - } - }) as unknown as TKongVault - -describe('buildVaultSuggestions', () => { - it('dedupes repeated vault matches before applying the two-item cap', () => { - const usdcVault = buildVault({ - address: '0x0000000000000000000000000000000000000001', - assetSymbol: 'USDC', - tvl: 2_000_000, - apr: 0.06 - }) - const daiVault = buildVault({ - address: '0x0000000000000000000000000000000000000002', - assetSymbol: 'DAI', - tvl: 1_500_000, - apr: 0.05 - }) - const wethVault = buildVault({ - address: '0x0000000000000000000000000000000000000003', - assetSymbol: 'WETH', - tvl: 1_250_000, - apr: 0.05 - }) - - const detectedTokens: TExternalToken[] = [ - { - address: '0x0000000000000000000000000000000000000101', - chainId: 1, - protocol: 'Aave V3', - underlyingSymbol: 'USDC', - underlyingAddress: '0x0000000000000000000000000000000000000010' - }, - { - address: '0x0000000000000000000000000000000000000102', - chainId: 1, - protocol: 'Compound V3', - underlyingSymbol: 'USDC', - underlyingAddress: '0x0000000000000000000000000000000000000010' - }, - { - address: '0x0000000000000000000000000000000000000103', - chainId: 1, - protocol: 'Spark', - underlyingSymbol: 'DAI', - underlyingAddress: '0x0000000000000000000000000000000000000011' - }, - { - address: '0x0000000000000000000000000000000000000104', - chainId: 1, - protocol: 'Morpho', - underlyingSymbol: 'WETH', - underlyingAddress: '0x0000000000000000000000000000000000000012' - } - ] - - const suggestions = buildVaultSuggestions( - detectedTokens, - { - [usdcVault.address]: usdcVault, - [daiVault.address]: daiVault, - [wethVault.address]: wethVault - }, - new Set() - ) - - expect(suggestions).toHaveLength(2) - expect(suggestions[0]?.vault).toBe(usdcVault) - expect(suggestions[1]?.vault).toBe(daiVault) - }) -}) diff --git a/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts b/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts deleted file mode 100644 index b4f2f5a35..000000000 --- a/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' -import { selectPreferredVault } from './getEligibleVaults' - -const buildVault = ({ - address, - assetSymbol, - tvl, - apr, - version = '3.0.0' -}: { - address: string - assetSymbol: string - tvl: number - apr: number - version?: string -}): TKongVault => - ({ - chainId: 1, - address, - name: `${assetSymbol} Vault`, - symbol: `yv${assetSymbol}`, - apiVersion: version, - decimals: 18, - asset: { - address: '0x0000000000000000000000000000000000000010', - name: assetSymbol, - symbol: assetSymbol, - decimals: 18 - }, - tvl, - performance: { - oracle: { apr, apy: apr }, - estimated: { - apr, - apy: apr, - type: 'estimated', - components: {} - }, - historical: { - net: apr, - weeklyNet: apr, - monthlyNet: apr, - inceptionNet: apr - } - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Single Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: true, - strategiesCount: 1, - riskLevel: 1, - staking: { - address: null, - available: false - } - }) as unknown as TKongVault - -describe('selectPreferredVault', () => { - it('prefers the highest-TVL qualifying vault', () => { - const smaller = buildVault({ - address: '0x0000000000000000000000000000000000000001', - assetSymbol: 'USDC', - tvl: 900_000, - apr: 0.06 - }) - const larger = buildVault({ - address: '0x0000000000000000000000000000000000000002', - assetSymbol: 'USDC', - tvl: 2_500_000, - apr: 0.05 - }) - - expect(selectPreferredVault([smaller, larger])).toBe(larger) - }) -}) diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts deleted file mode 100644 index cfeb80708..000000000 --- a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { filterVisiblePortfolioHoldings } from './portfolioVisibility' - -function makeVault(address: string, isHidden: boolean) { - return { - chainID: 1, - address, - name: `Vault ${address.slice(-4)}`, - symbol: 'yvTEST', - version: '3.0.0', - type: 'Standard', - kind: 'Single Strategy', - decimals: 18, - token: { - address, - name: 'Vault Token', - symbol: 'yvTEST', - description: '', - decimals: 18 - }, - tvl: { - totalAssets: 0n, - tvl: 0, - price: 0 - }, - apr: { - type: 'oracle', - netAPR: 0, - fees: { - performance: 0, - withdrawal: 0, - management: 0 - }, - extra: { - stakingRewardsAPR: 0, - gammaRewardAPR: 0 - }, - points: { - weekAgo: 0, - monthAgo: 0, - inception: 0 - }, - pricePerShare: { - today: 1, - weekAgo: 1, - monthAgo: 1 - }, - forwardAPR: { - type: 'oracle', - netAPR: 0, - composite: { - boost: 0, - poolAPY: 0, - boostedAPR: 0, - baseAPR: 0, - cvxAPR: 0, - rewardsAPR: 0, - v3OracleCurrentAPR: 0, - v3OracleStratRatioAPR: 0, - keepCRV: 0, - keepVELO: 0, - cvxKeepCRV: 0 - } - } - }, - featuringScore: 0, - strategies: [], - staking: { - address: null, - available: false, - source: '', - rewards: [] - }, - migration: { - available: false, - address: '0x0000000000000000000000000000000000000000', - contract: '0x0000000000000000000000000000000000000000' - }, - info: { - sourceURL: '', - riskLevel: 1, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden - } - } as any -} - -describe('filterVisiblePortfolioHoldings', () => { - it('hides hidden vaults when the persisted hidden-vault filter is off', () => { - const visible = makeVault('0x1111111111111111111111111111111111111111', false) - const hidden = makeVault('0x2222222222222222222222222222222222222222', true) - - expect(filterVisiblePortfolioHoldings([visible, hidden], false)).toEqual([visible]) - }) - - it('keeps hidden vaults when the persisted hidden-vault filter is on', () => { - const visible = makeVault('0x1111111111111111111111111111111111111111', false) - const hidden = makeVault('0x2222222222222222222222222222222222222222', true) - - expect(filterVisiblePortfolioHoldings([visible, hidden], true)).toEqual([visible, hidden]) - }) -}) diff --git a/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx b/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx deleted file mode 100644 index 41875ae6e..000000000 --- a/src/components/pages/vaults/components/SuggestedVaultCard.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it, vi } from 'vitest' - -import { SuggestedVaultCard } from './SuggestedVaultCard' - -vi.mock('@vaults/hooks/useVaultApyData', () => ({ - useVaultApyData: vi.fn() -})) - -const baseVault = { - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'A Very Long Vault Name That Should Truncate In The Card Header', - category: 'Some Category', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN' - }, - staking: { - source: 'None' - }, - tvl: { - tvl: 1234567 - } -} as unknown as TKongVaultInput - -function renderCard(vault: TKongVaultInput): string { - const originalWindow = globalThis.window - ;(globalThis as unknown as { window: unknown }).window = { - location: { href: 'https://yearn.fi/', hostname: 'yearn.fi' } - } - - try { - return renderToStaticMarkup( - - - - ) - } finally { - ;(globalThis as unknown as { window: unknown }).window = originalWindow - } -} - -describe('SuggestedVaultCard', () => { - it('uses 30D APY and no HIST prefix when est APY is unavailable', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'historical', - baseForwardApr: 0, - netApr: 0.1234, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('30D APY') - expect(html).not.toContain('Hist.') - }) - - it('uses Est. APY when est APY is available', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1234, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('Est. APY') - }) - - it('truncates the vault title to a single line', () => { - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const html = renderCard(baseVault) - expect(html).toContain('class="truncate') - }) -}) diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx deleted file mode 100644 index 48e4920a1..000000000 --- a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { MobileKeyMetrics, YvUsdApyStatBox } from './QuickStatsGrid' - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - address: undefined, - isActive: false - }) -})) - -vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ - VaultForwardAPY: () =>
{'Default APY'}
-})) - -vi.mock('@pages/vaults/components/table/APYDetailsModal', () => ({ - APYDetailsModal: ({ isOpen, title, children }: { isOpen: boolean; title: string; children: ReactNode }) => - isOpen ? ( -
-

{title}

- {children} -
- ) : null -})) - -const TEST_VAULT = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234 - } -} - -describe('MobileKeyMetrics', () => { - it('renders a custom APY box override when provided', () => { - const html = renderToStaticMarkup( - {'Custom APY'}
} - /> - ) - - expect(html).toContain('Custom APY') - expect(html).not.toContain('Default APY') - }) -}) - -describe('YvUsdApyStatBox', () => { - it('renders the locked variant by default with the unlocked toggle affordance', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Locked') - expect(html).toContain('9.00%') - expect(html).toContain('Switch to unlocked APY display') - expect(html).not.toContain('data-testid="apy-modal"') - }) - - it('formats large APY values with the shared significant-digit rules', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('118%') - expect(html).not.toContain('117.77%') - }) - - it('renders the controlled variant when provided', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Unlocked') - expect(html).toContain('5.00%') - expect(html).toContain('Switch to locked APY display') - }) - - it('shows the Infinifi points icon whenever yvUSD has points', () => { - const lockedHtml = renderToStaticMarkup( - - ) - const unlockedHtml = renderToStaticMarkup( - - ) - - expect(lockedHtml).toContain('aria-label="Infinifi points"') - expect(unlockedHtml).toContain('aria-label="Infinifi points"') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts b/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts deleted file mode 100644 index c5daebc3e..000000000 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' -import { YVUSD_LOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' -import { describe, expect, it } from 'vitest' -import { extractCurvePools, getVaultDocsLinks, resolveCurveDepositUrl } from './VaultInfoSection' - -const TOKEN_ADDRESS = '0x1111111111111111111111111111111111111111' -const POOL_ADDRESS = '0x2222222222222222222222222222222222222222' -const DEFAULT_VAULT_ADDRESS = '0x3333333333333333333333333333333333333333' - -function createVaultTokenAddress(address = DEFAULT_VAULT_ADDRESS): `0x${string}` { - return address as `0x${string}` -} - -describe('getVaultDocsLinks', () => { - it('returns yvUSD docs by address', () => { - expect(getVaultDocsLinks(YVUSD_LOCKED_ADDRESS, 'yvUSD', '2')).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/yvusd/', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/yvusd' - }) - }) - - it('returns yBOLD docs by address', () => { - expect(getVaultDocsLinks(YBOLD_VAULT_ADDRESS, 'yBOLD', '2')).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/v3/overview', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/yBold' - }) - }) - - it('returns yCRV docs by symbol', () => { - expect( - getVaultDocsLinks(createVaultTokenAddress('0x1111111111111111111111111111111111111111'), 'ycrv', '2') - ).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/ylockers/ycrv/overview' - }) - }) - - it('returns yYB docs by symbol', () => { - expect( - getVaultDocsLinks(createVaultTokenAddress('0x1111111111111111111111111111111111111112'), 'yyb', '2') - ).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/ylockers/yyb/overview' - }) - }) - - it('returns v3 docs by version fallback', () => { - expect(getVaultDocsLinks(DEFAULT_VAULT_ADDRESS as `0x${string}`, 'some-symbol', '3')).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/v3/overview', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/v3' - }) - }) - - it('returns v2 docs by version fallback', () => { - expect(getVaultDocsLinks(DEFAULT_VAULT_ADDRESS as `0x${string}`, 'some-symbol', '2')).toStrictEqual({ - developerDocumentationUrl: 'https://docs.yearn.fi/developers/building-on-yearn', - userDocumentationUrl: 'https://docs.yearn.fi/getting-started/products/yvaults/v2' - }) - }) -}) - -describe('extractCurvePools', () => { - it('extracts pools from canonical data.poolData shape', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/deposit'] } }] - } - }) - - expect(pools).toHaveLength(1) - }) - - it('returns empty list for root-level array payloads', () => { - const pools = extractCurvePools([ - { lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/deposit'] } } - ]) - - expect(pools).toEqual([]) - }) -}) - -describe('resolveCurveDepositUrl', () => { - it('normalizes the deposit URL when lpTokenAddress matches', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lpTokenAddress: TOKEN_ADDRESS, poolUrls: { deposit: ['https://curve.fi/lp-match'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, TOKEN_ADDRESS)).toBe('https://www.curve.finance/lp-match') - }) - - it('returns deposit URL when pool address matches', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ address: POOL_ADDRESS, poolUrls: { deposit: ['https://www.curve.finance/address-match'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, POOL_ADDRESS)).toBe('https://www.curve.finance/address-match') - }) - - it('ignores legacy key variants and returns empty string', () => { - const pools = extractCurvePools({ - data: { - poolData: [{ lp_token_address: TOKEN_ADDRESS, poolURLs: { deposit: ['https://curve.fi/legacy'] } }] - } - }) - - expect(resolveCurveDepositUrl(pools, TOKEN_ADDRESS)).toBe('') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultsListHead.test.ts b/src/components/pages/vaults/components/detail/VaultsListHead.test.ts deleted file mode 100644 index 9ee31f604..000000000 --- a/src/components/pages/vaults/components/detail/VaultsListHead.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT } from './strategiesLayout' - -describe('VaultsListHead desktop layout', () => { - it('uses tightened strategy panel desktop column classes', () => { - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('col-span-11') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('col-span-12') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') - expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') - }) -}) diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts deleted file mode 100644 index fc9a6c07f..000000000 --- a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' - -describe('VaultsListStrategy desktop layout', () => { - it('uses tightened strategy row desktop column classes with balanced desktop title wrapping', () => { - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('md:col-span-11') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('md:col-span-12') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') - expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameLabelDesktopWrapClass).toBe( - 'md:[display:-webkit-box] md:[-webkit-box-orient:vertical] md:[-webkit-line-clamp:2] md:[text-wrap:balance] md:whitespace-normal' - ) - }) -}) diff --git a/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts b/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts deleted file mode 100644 index 7d86483cc..000000000 --- a/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DESKTOP_WIDGET_OFFSET_CSS_VAR, - getDesktopWidgetHeightClassNames, - resolveDesktopWidgetHeaderOffset -} from './desktopWidgetSizing' - -describe('resolveDesktopWidgetHeaderOffset', () => { - it('rounds the combined base offset, measured header height, and widget padding', () => { - expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72.2, headerHeight: 180.6 })).toBe(269) - }) - - it('returns null until a positive header height is available', () => { - expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72, headerHeight: 0 })).toBeNull() - }) -}) - -describe('getDesktopWidgetHeightClassNames', () => { - it('uses the compressed header offset by default for both container and stack sizing', () => { - expect(getDesktopWidgetHeightClassNames()).toEqual({ - container: `md:h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)] max-h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)]`, - stack: `max-h-[calc(100vh-16px-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR}))]` - }) - }) - - it('supports an alternate offset variable when the layout needs a different measurement source', () => { - expect(getDesktopWidgetHeightClassNames('--vault-header-expanded-offset')).toEqual({ - container: - 'md:h-[calc(100vh-var(--vault-header-expanded-offset)-16px)] max-h-[calc(100vh-var(--vault-header-expanded-offset)-16px)]', - stack: 'max-h-[calc(100vh-16px-var(--vault-header-expanded-offset))]' - }) - }) -}) diff --git a/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx b/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx deleted file mode 100644 index ca3068ed5..000000000 --- a/src/components/pages/vaults/components/filters/VaultVersionToggle.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' - -import { VaultVersionToggle } from './VaultVersionToggle' - -function renderToggle(entry: string): string { - return renderToStaticMarkup( - - - - ) -} - -describe('VaultVersionToggle', () => { - it('marks all vaults active when no type param', () => { - const html = renderToggle('/vaults') - expect(html).toMatch(/data-active="true".*All Vaults/) - expect(html).toMatch(/data-active="false".*Single Asset/) - expect(html).toMatch(/data-active="false".*LP Token/) - expect(html).not.toMatch(/v3 Strategies/) - }) - - it('marks lp token vaults active when type=lp', () => { - const html = renderToggle('/vaults?type=lp') - expect(html).toMatch(/data-active="true".*LP Token/) - expect(html).toMatch(/data-active="false".*Single Asset/) - expect(html).toMatch(/data-active="false".*All Vaults/) - }) - - it('marks single asset vaults active when type=single', () => { - const html = renderToggle('/vaults?type=single') - expect(html).toMatch(/data-active="false".*All Vaults/) - expect(html).toMatch(/data-active="true".*Single Asset/) - expect(html).toMatch(/data-active="false".*LP Token/) - }) -}) diff --git a/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx b/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx deleted file mode 100644 index 2c72effc4..000000000 --- a/src/components/pages/vaults/components/list/VaultsListEmpty.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ComponentProps } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -import { VaultsListEmpty } from './VaultsListEmpty' - -function renderEmptyState(overrides: Partial> = {}): string { - return renderToStaticMarkup() -} - -describe('VaultsListEmpty', () => { - it('renders blocking filter toggles and a search action when results are recoverable', () => { - const html = renderEmptyState({ - currentSearch: 'USDC', - hiddenByFiltersCount: 3, - blockingFilterActions: [ - { key: 'showLegacyVaults', label: 'Show legacy vaults', additionalResults: 2, onApply: vi.fn() }, - { key: 'showStrategies', label: 'Show single asset strategies', additionalResults: 1, onApply: vi.fn() } - ] - }) - - expect(html).toContain('No results for "USDC" with current filters.') - expect(html).toContain('3 vaults found that are hidden by filters. Enable them below.') - expect(html).toContain('Show legacy vaults (+2)') - expect(html).toContain('Show single asset strategies (+1)') - expect(html).toContain('Search') - expect(html).not.toContain('Show all results') - }) - - it('renders vault-not-found copy when no recoverable results exist', () => { - const html = renderEmptyState({ - currentSearch: 'NOT_A_VAULT', - blockingFilterActions: [] - }) - - expect(html).toContain('The vault "NOT_A_VAULT" does not exist') - expect(html).not.toContain('Show all results') - }) - - it('renders combination filter actions without zero-result suffixes', () => { - const html = renderEmptyState({ - currentSearch: 'ETH', - hiddenByFiltersCount: 2, - blockingFilterActions: [ - { key: 'showAllChains', label: 'Show all chains', additionalResults: 0, onApply: vi.fn() }, - { key: 'showAllCategories', label: 'Show all categories', additionalResults: 0, onApply: vi.fn() } - ] - }) - - expect(html).toContain('Show all chains') - expect(html).toContain('Show all categories') - expect(html).not.toContain('Show all chains (+0)') - expect(html).not.toContain('Show all categories (+0)') - }) -}) diff --git a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx deleted file mode 100644 index f215458d8..000000000 --- a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderToStaticMarkup } from 'react-dom/server' -import { MemoryRouter } from 'react-router' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { VaultsListRow } from './VaultsListRow' - -const { mockUseMediaQuery, mockUseYvUsdVaults } = vi.hoisted(() => ({ - mockUseMediaQuery: vi.fn(() => false), - mockUseYvUsdVaults: vi.fn((): any => ({ - metrics: undefined, - unlockedVault: undefined, - lockedVault: undefined - })) -})) - -vi.mock('@react-hookz/web', () => ({ - useMediaQuery: mockUseMediaQuery -})) - -vi.mock('@shared/contexts/useWallet', () => ({ - useWallet: () => ({ - getBalance: () => ({ raw: 0n, normalized: 0 }), - getToken: () => ({ value: 0 }) - }) -})) - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - address: undefined - }) -})) - -vi.mock('@hooks/usePlausible', () => ({ - usePlausible: () => vi.fn() -})) - -vi.mock('@pages/vaults/hooks/useYvUsdVaults', () => ({ - useYvUsdVaults: mockUseYvUsdVaults -})) - -vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ - VaultForwardAPY: () =>
{'APY'}
, - VaultForwardAPYInlineDetails: () =>
{'APY details'}
-})) - -vi.mock('@pages/vaults/components/table/VaultHistoricalAPY', () => ({ - VaultHistoricalAPY: () =>
{'Historical APY'}
-})) - -vi.mock('@pages/vaults/components/table/VaultHoldingsAmount', () => ({ - VaultHoldingsAmount: () =>
{'Holdings'}
-})) - -vi.mock('@pages/vaults/components/table/VaultRiskScoreTag', () => ({ - VaultRiskScoreTag: () =>
{'Risk'}
, - RiskScoreInlineDetails: () =>
{'Risk details'}
-})) - -function renderRowHtml(vault: TKongVaultInput): string { - const queryClient = new QueryClient() - - return renderToStaticMarkup( - - - - - - ) -} - -describe('VaultsListRow', () => { - beforeEach(() => { - mockUseMediaQuery.mockReturnValue(false) - mockUseYvUsdVaults.mockReturnValue({ - metrics: undefined, - unlockedVault: undefined, - lockedVault: undefined - }) - }) - - it('renders the desktop TVL tooltip trigger for standard vault rows', () => { - const vault = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - category: 'Test Category', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234, - totalAssets: 1234567 - }, - info: { - riskLevel: 3 - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('tvl-subline-tooltip') - }) - - it('stacks the yvUSD mobile up-to label above the APY value', () => { - mockUseMediaQuery.mockReturnValue(true) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('Up to') - expect(html).toContain('9.00%') - expect(html).toContain('inline-flex flex-col items-start') - }) - - it('formats yvUSD locked APY with shared significant-digit rounding in the list row', () => { - mockUseMediaQuery.mockReturnValue(true) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 1.1777, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('118%') - expect(html).not.toContain('117.77%') - expect(html).toContain('flex items-center justify-center gap-2 whitespace-nowrap') - }) - - it('positions the desktop yvUSD up-to label above the APY value without changing row flow', () => { - mockUseMediaQuery.mockReturnValue(false) - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('inline-flex items-center gap-2 text-right') - expect(html).toContain('relative inline-flex') - expect(html).toContain('absolute bottom-full left-0 mb-0.5') - }) - - it('shows the Infinifi points icon for yvUSD when either variant has points', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).toContain('aria-label="Infinifi points"') - }) - - it('does not show the Infinifi points icon for yvUSD without points', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: YVUSD_UNLOCKED_ADDRESS, - name: 'yvUSD', - symbol: 'yvUSD', - category: 'Stablecoin', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'USDC', - decimals: 6 - }, - apr: { - forwardAPR: { - netAPR: 0.05 - }, - netAPR: 0.05 - }, - tvl: { - tvl: 350, - totalAssets: 350_000_000 - }, - info: { - riskLevel: 2 - }, - staking: { - address: '0x0000000000000000000000000000000000000000' - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).not.toContain('aria-label="Infinifi points"') - }) - - it('does not show the Infinifi points icon for non-yvUSD rows', () => { - mockUseYvUsdVaults.mockReturnValue({ - metrics: { - unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: true }, - locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } - }, - unlockedVault: undefined, - lockedVault: undefined - }) - - const vault = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - category: 'Test Category', - kind: 'Multi Strategy', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 6 - }, - tvl: { - tvl: 1234, - totalAssets: 1234567 - }, - info: { - riskLevel: 3 - } - } as unknown as TKongVaultInput - - const html = renderRowHtml(vault) - - expect(html).not.toContain('aria-label="Infinifi points"') - }) -}) diff --git a/src/components/pages/vaults/components/table/APYSubline.test.ts b/src/components/pages/vaults/components/table/APYSubline.test.ts deleted file mode 100644 index cab527cd5..000000000 --- a/src/components/pages/vaults/components/table/APYSubline.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getApySublineLines } from './APYSubline' - -describe('getApySublineLines', () => { - it('returns Kelp + EigenLayer lines when both rewards apply', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: true, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+1x Kelp Miles', '+1x EigenLayer Points']) - }) - - it('returns single Kelp line', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: true, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+ 1x Kelp Miles']) - }) - - it('returns ARB line', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: true, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual(['+ 2500 ARB/week']) - }) - - it('returns eligible for extra rewards when Spectra/Steer applies', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: true, - steerPointsPerDollar: 2, - isEligibleForSpectraBoost: false - }) - ).toEqual(['Eligible for Extra Rewards']) - }) - - it('returns empty list when no subline data applies', () => { - expect( - getApySublineLines({ - hasPendleArbRewards: false, - hasKelpNEngenlayer: false, - hasKelp: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - isEligibleForSpectraBoost: false - }) - ).toEqual([]) - }) -}) diff --git a/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx b/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx deleted file mode 100644 index 251483457..000000000 --- a/src/components/pages/vaults/components/table/VaultForwardAPY.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { fireEvent, render } from '@testing-library/react' -import { act } from 'react' -import { describe, expect, it, vi } from 'vitest' - -import { VaultForwardAPY } from './VaultForwardAPY' - -vi.mock('@vaults/hooks/useVaultApyData', () => ({ - useVaultApyData: vi.fn() -})) - -const vault = { - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - apr: { forwardAPR: { type: '' }, type: '' }, - staking: { source: 'None' } -} as unknown as TKongVaultInput - -describe('VaultForwardAPY', () => { - it('shows subline tooltip when hovering the value', () => { - vi.useFakeTimers() - vi.mocked(useVaultApyData).mockReturnValue({ - mode: 'spot', - baseForwardApr: 0.1, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: true, - hasKelp: false, - hasKelpNEngenlayer: false, - isEligibleForSteer: false, - steerPointsPerDollar: 0, - katanaExtras: undefined, - katanaThirtyDayApr: undefined, - katanaEstApr: undefined - }) - - const { container, queryByText } = render( - - ) - const trigger = container.querySelector('.apy-subline-tooltip') - - expect(trigger).not.toBeNull() - expect(queryByText('+ 2500 ARB/week')).toBeNull() - - fireEvent.mouseEnter(trigger as Element) - act(() => { - vi.advanceTimersByTime(150) - }) - - expect(queryByText('+ 2500 ARB/week')).not.toBeNull() - vi.useRealTimers() - }) -}) diff --git a/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx b/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx deleted file mode 100644 index d9488ab10..000000000 --- a/src/components/pages/vaults/components/table/apyDisplayConfig.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { KATANA_CHAIN_ID, SPECTRA_MARKET_VAULT_ADDRESSES } from '@pages/vaults/constants/addresses' -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import type { TVaultApyData } from '@pages/vaults/hooks/useVaultApyData' -import { isValidElement } from 'react' -import { describe, expect, it } from 'vitest' -import { resolveHistoricalApyDisplayConfig } from './apyDisplayConfig' - -const KATANA_SPECTRA_VAULT = { - version: '3.0.0', - chainID: KATANA_CHAIN_ID, - address: SPECTRA_MARKET_VAULT_ADDRESSES[0], - token: { - address: '0x0000000000000000000000000000000000000001' - }, - apr: { - type: '', - points: { - monthAgo: 0.1, - weekAgo: 0.1 - } - } -} as unknown as TKongVaultInput - -const KATANA_APY_DATA = { - mode: 'katana', - baseForwardApr: 0, - netApr: 0, - rewardsAprSum: 0, - isBoosted: false, - hasPendleArbRewards: false, - hasKelp: false, - hasKelpNEngenlayer: false, - katanaExtras: { - fixedRateKatanaRewards: 4, - katanaAppRewardsAPR: 3, - steerPointsPerDollar: 0 - }, - katanaThirtyDayApr: 7.1, - katanaEstApr: 7 -} as TVaultApyData - -describe('resolveHistoricalApyDisplayConfig', () => { - it('includes Spectra boost details in Katana 30 Day APY modal content', () => { - const { modalConfig } = resolveHistoricalApyDisplayConfig({ - currentVault: KATANA_SPECTRA_VAULT, - data: KATANA_APY_DATA, - showSublineTooltip: true - }) - - expect(modalConfig?.canOpen).toBe(true) - expect(isValidElement(modalConfig?.content)).toBe(true) - if (isValidElement<{ isEligibleForSpectraBoost?: boolean }>(modalConfig?.content)) { - expect(modalConfig.content.props.isEligibleForSpectraBoost).toBe(true) - } - }) -}) diff --git a/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx b/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx deleted file mode 100644 index f96ff2e04..000000000 --- a/src/components/pages/vaults/components/widget/InputTokenAmount.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -async function loadInputTokenAmount() { - return (await import('./InputTokenAmount')).InputTokenAmount -} - -vi.mock('wagmi', () => ({ - useAccount: () => ({ - address: '0x0000000000000000000000000000000000000001' - }) -})) - -vi.mock('@hooks/usePlausible', () => ({ - usePlausible: () => vi.fn() -})) - -vi.mock('@shared/contexts/useWeb3', () => ({ - useWeb3: () => ({ - openLoginModal: vi.fn() - }) -})) - -vi.mock('@shared/utils', () => ({ - cl: (...classes: Array) => classes.filter(Boolean).join(' '), - formatTAmount: ({ value, decimals }: { value: bigint; decimals: number }) => { - const divisor = 10n ** BigInt(decimals) - const whole = value / divisor - const remainder = value % divisor - if (remainder === 0n) { - return whole.toString() - } - - return `${whole.toString()}.${remainder.toString().padStart(decimals, '0').replace(/0+$/, '')}` - }, - simpleToExact: vi.fn(() => 0n) -})) - -vi.mock('@shared/components/TokenLogo', () => ({ - TokenLogo: (props: { src?: string; altSrc?: string }) => -})) - -describe('InputTokenAmount', () => { - it('uses the explicit token logo URI for the selected input token', async () => { - const InputTokenAmount = await loadInputTokenAmount() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('https://example.com/input-logo.png') - }) - - it('renders the display balance separately from the max-action balance', async () => { - const InputTokenAmount = await loadInputTokenAmount() - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Balance: 4 yvUSD') - }) -}) diff --git a/src/components/pages/vaults/components/widget/TokenSelector.test.tsx b/src/components/pages/vaults/components/widget/TokenSelector.test.tsx deleted file mode 100644 index 00512f49c..000000000 --- a/src/components/pages/vaults/components/widget/TokenSelector.test.tsx +++ /dev/null @@ -1,649 +0,0 @@ -import type { TToken } from '@shared/types' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { TokenSelector } from './TokenSelector' - -const BASE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000001' as const -const VAULT_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000002' as const -const STAKING_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000003' as const -const LEGACY_USDAF_ADDRESS = '0x85E30b8b263bC64d94b827ed450F2EdFEE8579dA' as const - -const { mockUseWallet, mockUseYearn } = vi.hoisted(() => ({ - mockUseWallet: vi.fn(), - mockUseYearn: vi.fn() -})) - -vi.mock('@shared/contexts/useWallet', () => ({ - useWallet: mockUseWallet -})) - -vi.mock('@shared/contexts/WithTokenList', () => ({ - useTokenList: () => ({ - tokenLists: {} - }) -})) - -vi.mock('@shared/contexts/useYearn', () => ({ - useYearn: mockUseYearn -})) - -function buildToken(overrides: Partial = {}): TToken { - return { - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - decimals: 18, - chainID: 1, - value: 0, - balance: { - raw: 5n, - normalized: 5, - display: '5', - decimals: 18 - }, - ...overrides - } -} - -describe('TokenSelector', () => { - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 0 }) - }) - - it('lets extra tokens override selector metadata and logo sources for matching addresses', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken() - } - }, - getToken: () => buildToken() - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - extraTokens={[ - buildToken({ - name: 'Override Token', - symbol: 'OVR', - logoURI: 'https://example.com/override.png' - }) - ]} - /> - ) - - expect(html).toContain('Override Token') - expect(html).toContain('OVR') - expect(html).toContain('https://example.com/override.png') - expect(html).not.toContain('Base Token') - }) - - it('preserves wallet balance and value when an extra token only overrides metadata', () => { - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 0 }) - }) - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - name: 'Wallet Token', - symbol: 'WLT', - decimals: 0, - value: 100, - balance: { - raw: 1234n, - normalized: 1234, - display: '1234', - decimals: 0 - } - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Lower Value Token', - symbol: 'LVT', - decimals: 0, - value: 50, - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 0 - } - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Lower Value Token', - symbol: 'LVT', - decimals: 0, - value: 50, - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 0 - } - }) - } - - return buildToken({ - name: 'Wallet Token', - symbol: 'WLT', - decimals: 0, - value: 100, - balance: { - raw: 1234n, - normalized: 1234, - display: '1234', - decimals: 0 - } - }) - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="deposit" - extraTokens={[ - buildToken({ - name: 'Override Token', - symbol: 'OVR', - decimals: 0, - value: 0, - balance: { - raw: 0n, - normalized: 0, - display: '0', - decimals: 0 - } - }) - ]} - /> - ) - - expect(html).toContain('Override Token') - expect(html).toContain('1,234') - expect(html.indexOf('Override Token')).toBeLessThan(html.indexOf('Lower Value Token')) - }) - - it('uses the asset logo for vault and staking entries', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - logoURI: 'https://example.com/base.png' - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Vault Token', - symbol: 'vBASE', - logoURI: 'https://example.com/vault.png' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Staking Token', - symbol: 'stBASE', - logoURI: 'https://example.com/staking.png' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === BASE_TOKEN_ADDRESS) { - return buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Base Token', - symbol: 'BASE', - logoURI: 'https://example.com/base.png' - }) - } - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Vault Token', - symbol: 'vBASE', - logoURI: 'https://example.com/vault.png' - }) - } - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Staking Token', - symbol: 'stBASE', - logoURI: 'https://example.com/staking.png' - }) - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('https://example.com/base.png') - expect(html).not.toContain('https://example.com/vault.png') - expect(html).not.toContain('https://example.com/staking.png') - }) - - it('never shows hidden vault share or staking tokens', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('Base Token') - expect(html).not.toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('keeps the hidden vault share token available only for explicit unstake selection', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="withdraw" - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - allowHiddenVaultTokenSelection - /> - ) - - expect(html).toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('keeps hidden vault share tokens excluded for withdraw selectors without explicit unstake allowance', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Hidden Vault Token', - symbol: 'kpdWETH' - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Hidden Staking Token', - symbol: 'stkWETH' - }) - } - return buildToken() - } - }) - mockUseYearn.mockReturnValue({ - allVaults: { - [VAULT_TOKEN_ADDRESS]: { - chainID: 1, - version: '3.0.0', - address: VAULT_TOKEN_ADDRESS, - token: { - address: BASE_TOKEN_ADDRESS, - symbol: 'WETH', - name: 'Wrapped Ether', - description: '', - decimals: 18 - }, - staking: { - address: STAKING_TOKEN_ADDRESS, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } - }, - getPrice: () => ({ normalized: 0 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="withdraw" - assetAddress={BASE_TOKEN_ADDRESS} - vaultAddress={VAULT_TOKEN_ADDRESS} - stakingAddress={STAKING_TOKEN_ADDRESS} - /> - ) - - expect(html).not.toContain('kpdWETH') - expect(html).not.toContain('stkWETH') - }) - - it('never shows locally deprecated legacy tokens from the token registry', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken(), - [LEGACY_USDAF_ADDRESS]: buildToken({ - address: LEGACY_USDAF_ADDRESS, - name: 'USDaf Stablecoin', - symbol: 'USDaf' - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === LEGACY_USDAF_ADDRESS) { - return buildToken({ - address: LEGACY_USDAF_ADDRESS, - name: 'USDaf Stablecoin', - symbol: 'USDaf' - }) - } - return buildToken() - } - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - assetAddress={BASE_TOKEN_ADDRESS} - /> - ) - - expect(html).toContain('Base Token') - expect(html).not.toContain('USDaf Stablecoin') - }) - - it('keeps the base deposit asset visible while filtering other low-value pinned options', () => { - mockUseWallet.mockReturnValue({ - isLoading: false, - balances: { - 1: { - [BASE_TOKEN_ADDRESS]: buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Visible Token', - symbol: 'VIS', - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 18 - } - }), - [VAULT_TOKEN_ADDRESS]: buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Dust Asset', - symbol: 'DST', - balance: { - raw: 5_000_000_000_000_000n, - normalized: 0.005, - display: '0.005', - decimals: 18 - } - }), - [STAKING_TOKEN_ADDRESS]: buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Pinned Dust Token', - symbol: 'PDT', - balance: { - raw: 4_000_000_000_000_000n, - normalized: 0.004, - display: '0.004', - decimals: 18 - } - }) - } - }, - getToken: ({ address }: { address: string }) => { - if (address === VAULT_TOKEN_ADDRESS) { - return buildToken({ - address: VAULT_TOKEN_ADDRESS, - name: 'Dust Asset', - symbol: 'DST', - balance: { - raw: 5_000_000_000_000_000n, - normalized: 0.005, - display: '0.005', - decimals: 18 - } - }) - } - if (address === STAKING_TOKEN_ADDRESS) { - return buildToken({ - address: STAKING_TOKEN_ADDRESS, - name: 'Pinned Dust Token', - symbol: 'PDT', - balance: { - raw: 4_000_000_000_000_000n, - normalized: 0.004, - display: '0.004', - decimals: 18 - } - }) - } - - return buildToken({ - address: BASE_TOKEN_ADDRESS, - name: 'Visible Token', - symbol: 'VIS', - balance: { - raw: 1n, - normalized: 1, - display: '1', - decimals: 18 - } - }) - } - }) - mockUseYearn.mockReturnValue({ - allVaults: {}, - getPrice: () => ({ normalized: 1 }) - }) - - const html = renderToStaticMarkup( - undefined} - chainId={1} - mode="deposit" - assetAddress={VAULT_TOKEN_ADDRESS} - priorityTokens={{ 1: [VAULT_TOKEN_ADDRESS, STAKING_TOKEN_ADDRESS] }} - topTokens={{ 1: [VAULT_TOKEN_ADDRESS, STAKING_TOKEN_ADDRESS] }} - /> - ) - - expect(html).toContain('Visible Token') - expect(html).toContain('Dust Asset') - expect(html).not.toContain('Pinned Dust Token') - }) -}) diff --git a/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts b/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts deleted file mode 100644 index 87299233e..000000000 --- a/src/components/pages/vaults/components/widget/deposit/approvalSpender.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import { getDepositApprovalSpender } from './approvalSpender' - -describe('getDepositApprovalSpender', () => { - it('uses the zap router for direct deposit routes when a router address is present', () => { - expect( - getDepositApprovalSpender({ - routeType: 'DIRECT_DEPOSIT', - destinationToken: '0x00000000000000000000000000000000000000aa', - routerAddress: '0x00000000000000000000000000000000000000bb', - vaultSymbol: 'yvUSD' - }) - ).toEqual({ - spenderAddress: getAddress('0x00000000000000000000000000000000000000bb'), - spenderName: 'Yearn Zap' - }) - }) - - it('falls back to the vault for plain direct deposits', () => { - expect( - getDepositApprovalSpender({ - routeType: 'DIRECT_DEPOSIT', - destinationToken: '0x00000000000000000000000000000000000000aa', - vaultSymbol: 'yvUSD' - }) - ).toEqual({ - spenderAddress: getAddress('0x00000000000000000000000000000000000000aa'), - spenderName: 'yvUSD' - }) - }) -}) diff --git a/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts b/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts deleted file mode 100644 index 82001dc9a..000000000 --- a/src/components/pages/vaults/components/widget/deposit/tokenSelectorFiltering.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { getStructurallyExcludedDepositTokenAddresses } from './tokenSelectorFiltering' - -const DESTINATION_VAULT = '0x0000000000000000000000000000000000000001' as Address -const CANDIDATE_VAULT = '0x0000000000000000000000000000000000000002' as Address -const IRRELEVANT_VAULT = '0x0000000000000000000000000000000000000003' as Address -const OTHER_ASSET = '0x0000000000000000000000000000000000000004' as Address -const HIDDEN_STAKING = '0x0000000000000000000000000000000000000005' as Address - -describe('getStructurallyExcludedDepositTokenAddresses', () => { - it('excludes vault share tokens whose underlying asset is the destination vault', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: { - [CANDIDATE_VAULT]: { - chainID: 1, - version: '3.0.0', - address: CANDIDATE_VAULT, - token: { - address: DESTINATION_VAULT, - symbol: 'DEST', - name: 'Destination Vault', - description: '', - decimals: 18 - } - } as never, - [IRRELEVANT_VAULT]: { - chainID: 1, - version: '3.0.0', - address: IRRELEVANT_VAULT, - token: { - address: OTHER_ASSET, - symbol: 'OTHER', - name: 'Other Asset', - description: '', - decimals: 18 - } - } as never - }, - destinationVaultAddress: DESTINATION_VAULT - }) - - expect(excluded).toContain(CANDIDATE_VAULT) - expect(excluded).not.toContain(IRRELEVANT_VAULT) - }) - - it('excludes locked yvUSD for deposits even when the destination vault is unrelated', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: {}, - destinationVaultAddress: OTHER_ASSET - }) - - expect(excluded).toContain(YVUSD_LOCKED_ADDRESS) - }) - - it('excludes hidden vault share and staking tokens from deposit selectors', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: { - [IRRELEVANT_VAULT]: { - chainID: 1, - version: '3.0.0', - address: IRRELEVANT_VAULT, - token: { - address: OTHER_ASSET, - symbol: 'OTHER', - name: 'Other Asset', - description: '', - decimals: 18 - }, - staking: { - address: HIDDEN_STAKING, - available: true, - source: '', - rewards: null - }, - info: { - sourceURL: '', - riskLevel: 0, - riskScore: [], - riskScoreComment: '', - uiNotice: '', - isRetired: false, - isBoosted: false, - isHighlighted: false, - isHidden: true - } - } as never - }, - destinationVaultAddress: DESTINATION_VAULT - }) - - expect(excluded).toContain(IRRELEVANT_VAULT) - expect(excluded).toContain(HIDDEN_STAKING) - }) - - it('does not exclude unlocked yvUSD for locked yvUSD deposits', () => { - const excluded = getStructurallyExcludedDepositTokenAddresses({ - allVaults: {}, - destinationVaultAddress: YVUSD_LOCKED_ADDRESS - }) - - expect(excluded).not.toContain(YVUSD_UNLOCKED_ADDRESS) - }) -}) diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts deleted file mode 100644 index a782a2c7b..000000000 --- a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { resolveDepositRouteType } from './useDepositRoute' - -const ASSET = '0x0000000000000000000000000000000000000001' as Address -const VAULT = '0x0000000000000000000000000000000000000002' as Address -const STAKING = '0x0000000000000000000000000000000000000003' as Address -const OTHER = '0x0000000000000000000000000000000000000004' as Address - -describe('resolveDepositRouteType', () => { - it('returns DIRECT_DEPOSIT for asset-to-vault deposits', () => { - const route = resolveDepositRouteType({ - depositToken: ASSET, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_DEPOSIT') - }) - - it('returns DIRECT_STAKE for vault-to-staking deposits', () => { - const route = resolveDepositRouteType({ - depositToken: VAULT, - assetAddress: ASSET, - destinationToken: STAKING, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_STAKE') - }) - - it('returns ENSO for non-direct routes when Enso is enabled', () => { - const route = resolveDepositRouteType({ - depositToken: OTHER, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: true - }) - - expect(route).toBe('ENSO') - }) - - it('returns NO_ROUTE for non-direct routes when Enso is disabled', () => { - const route = resolveDepositRouteType({ - depositToken: OTHER, - assetAddress: ASSET, - destinationToken: VAULT, - vaultAddress: VAULT, - stakingAddress: STAKING, - ensoEnabled: false - }) - - expect(route).toBe('NO_ROUTE') - }) -}) diff --git a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts b/src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts deleted file mode 100644 index 066cfa890..000000000 --- a/src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveCompletionDeferral, shouldAutoContinuePermitSuccess } from './transactionOverlay.helpers' - -describe('shouldAutoContinuePermitSuccess', () => { - it('continues permit steps once the next step is ready', () => { - expect( - shouldAutoContinuePermitSuccess({ - overlayState: 'success', - executedStepIsPermit: true, - executedStepAutoContinues: true, - executedStepCompletesFlow: false, - currentStepLabel: 'Deposit', - executedStepLabel: 'Sign Permit', - isStepReady: true, - hasAdvancedFromStep: null, - hasAutoContinuedFromStep: null - }) - ).toBe(true) - }) - - it('does not continue permit steps before the next step changes and becomes ready', () => { - expect( - shouldAutoContinuePermitSuccess({ - overlayState: 'success', - executedStepIsPermit: true, - executedStepAutoContinues: true, - executedStepCompletesFlow: false, - currentStepLabel: 'Sign Permit', - executedStepLabel: 'Sign Permit', - isStepReady: false, - hasAdvancedFromStep: null, - hasAutoContinuedFromStep: null - }) - ).toBe(false) - }) - - it('does not continue terminal permit steps', () => { - expect( - shouldAutoContinuePermitSuccess({ - overlayState: 'success', - executedStepIsPermit: true, - executedStepAutoContinues: true, - executedStepCompletesFlow: true, - currentStepLabel: 'Done', - executedStepLabel: 'Sign Permit', - isStepReady: true, - hasAdvancedFromStep: null, - hasAutoContinuedFromStep: null - }) - ).toBe(false) - }) -}) - -describe('resolveCompletionDeferral', () => { - it('does not run completion callbacks for non-terminal success states', () => { - expect( - resolveCompletionDeferral({ - completedAllSteps: false, - deferOnAllCompleteUntilClose: false, - deferOnAllCompleteUntilConfettiEnd: true, - stepShowsConfetti: true - }) - ).toBe('none') - }) - - it('prefers close deferral when explicitly requested', () => { - expect( - resolveCompletionDeferral({ - completedAllSteps: true, - deferOnAllCompleteUntilClose: true, - deferOnAllCompleteUntilConfettiEnd: true, - stepShowsConfetti: true - }) - ).toBe('after-close') - }) - - it('defers terminal completion until confetti ends when configured', () => { - expect( - resolveCompletionDeferral({ - completedAllSteps: true, - deferOnAllCompleteUntilClose: false, - deferOnAllCompleteUntilConfettiEnd: true, - stepShowsConfetti: true - }) - ).toBe('after-confetti') - }) - - it('falls back to immediate completion when no confetti is shown', () => { - expect( - resolveCompletionDeferral({ - completedAllSteps: true, - deferOnAllCompleteUntilClose: false, - deferOnAllCompleteUntilConfettiEnd: true, - stepShowsConfetti: false - }) - ).toBe('immediate') - }) -}) diff --git a/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts b/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts deleted file mode 100644 index d0027d78e..000000000 --- a/src/components/pages/vaults/components/widget/tokenSelectorList.utils.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { TToken } from '@shared/types' -import { describe, expect, it } from 'vitest' -import { - filterAndSortTokenSelectorTokens, - getDepositMinValueExemptTokenAddresses, - getExplicitTokenAddresses -} from './tokenSelectorList.utils' - -const TOKEN_A = '0x0000000000000000000000000000000000000001' as const -const TOKEN_B = '0x0000000000000000000000000000000000000002' as const -const TOKEN_C = '0x0000000000000000000000000000000000000003' as const - -function buildToken({ - address, - name, - symbol, - value = 0, - normalizedBalance = 0, - rawBalance = 0n -}: { - address: `0x${string}` - name: string - symbol: string - value?: number - normalizedBalance?: number - rawBalance?: bigint -}): TToken { - return { - address, - name, - symbol, - decimals: 18, - chainID: 1, - logoURI: undefined, - value, - balance: { - raw: rawBalance, - normalized: normalizedBalance, - display: String(normalizedBalance), - decimals: 18 - } - } -} - -describe('filterAndSortTokenSelectorTokens', () => { - it('puts configured top tokens first before sorting the rest by derived USD value', () => { - const tokens = [ - buildToken({ - address: TOKEN_A, - name: 'High Raw Balance', - symbol: 'RAW', - normalizedBalance: 100, - rawBalance: 100n - }), - buildToken({ - address: TOKEN_B, - name: 'High USD Value', - symbol: 'USD', - value: 150, - normalizedBalance: 5, - rawBalance: 5n - }), - buildToken({ - address: TOKEN_C, - name: 'No Price', - symbol: 'NOP', - normalizedBalance: 200, - rawBalance: 200n - }) - ] - - const filtered = filterAndSortTokenSelectorTokens({ - tokens, - mode: 'deposit', - topTokenAddresses: [TOKEN_A], - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase(), TOKEN_B.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_A) { - return 20 - } - if (token.address === TOKEN_B) { - return 150 - } - return 0 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A, TOKEN_B]) - }) - - it('hides tokens below the minimum USD value threshold unless they are explicit', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Dust Token', - symbol: 'DST', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Visible Token', - symbol: 'VIS', - normalizedBalance: 2, - rawBalance: 2n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase(), TOKEN_B.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_A) { - return 0.009 - } - return 0.02 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_B]) - }) - - it('keeps supported deposit tokens with positive balances visible when pricing is temporarily unavailable', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Known Unpriced Token', - symbol: 'KUT', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Unknown Unpriced Token', - symbol: 'UUT', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: () => 0 - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A]) - }) - - it('still hides known deposit-token dust when a valid price puts it below the minimum threshold', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Known Dust Token', - symbol: 'KDT', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - yearnKnownTokenAddresses: new Set([TOKEN_A.toLowerCase()]), - explicitTokenAddresses: new Set(), - getTokenUsdValue: () => 0.009 - }) - - expect(filtered).toEqual([]) - }) - - it('keeps an explicit custom token visible even when it is below the minimum USD value', () => { - const explicitTokenAddresses = getExplicitTokenAddresses({ - customAddress: TOKEN_A - }) - - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Unknown Token', - symbol: 'UNK', - normalizedBalance: 10, - rawBalance: 10n - }) - ], - mode: 'deposit', - searchText: TOKEN_A, - yearnKnownTokenAddresses: new Set(), - explicitTokenAddresses, - getTokenUsdValue: () => 0.009 - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_A]) - }) - - it('keeps the deposit asset exempt on its own chain even when the selector was opened from another chain', () => { - const minValueExemptTokenAddresses = getDepositMinValueExemptTokenAddresses({ - value: TOKEN_B, - assetAddress: TOKEN_A, - selectedChainId: 1, - assetChainId: 1 - }) - - expect(minValueExemptTokenAddresses.has(TOKEN_A.toLowerCase())).toBe(true) - expect(minValueExemptTokenAddresses.has(TOKEN_B.toLowerCase())).toBe(true) - - const mismatchedChainExemptions = getDepositMinValueExemptTokenAddresses({ - value: TOKEN_B, - assetAddress: TOKEN_A, - selectedChainId: 10, - assetChainId: 1 - }) - - expect(mismatchedChainExemptions.has(TOKEN_A.toLowerCase())).toBe(false) - expect(mismatchedChainExemptions.has(TOKEN_B.toLowerCase())).toBe(true) - }) - - it('hides low-value withdraw tokens unless they are explicit common options', () => { - const filtered = filterAndSortTokenSelectorTokens({ - tokens: [ - buildToken({ - address: TOKEN_A, - name: 'Dust Token', - symbol: 'DST', - normalizedBalance: 10, - rawBalance: 10n - }), - buildToken({ - address: TOKEN_B, - name: 'Pinned Common Token', - symbol: 'PIN', - normalizedBalance: 1, - rawBalance: 1n - }), - buildToken({ - address: TOKEN_C, - name: 'Visible Token', - symbol: 'VIS', - normalizedBalance: 2, - rawBalance: 2n - }) - ], - mode: 'withdraw', - explicitTokenAddresses: new Set([TOKEN_B.toLowerCase()]), - getTokenUsdValue: (token) => { - if (token.address === TOKEN_C) { - return 0.02 - } - return 0.009 - } - }) - - expect(filtered.map((token) => token.address)).toEqual([TOKEN_C, TOKEN_B]) - }) -}) diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts deleted file mode 100644 index 48e27065d..000000000 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Address } from 'viem' -import { describe, expect, it } from 'vitest' -import { resolveWithdrawRouteType } from './useWithdrawRoute' - -const ASSET = '0x0000000000000000000000000000000000000001' as Address -const OTHER = '0x0000000000000000000000000000000000000002' as Address - -describe('resolveWithdrawRouteType', () => { - it('returns DIRECT_UNSTAKE when withdrawing as unstake flow', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: ASSET, - assetAddress: ASSET, - withdrawalSource: 'staking', - chainId: 1, - outputChainId: 1, - isUnstake: true, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_UNSTAKE') - }) - - it('returns DIRECT_WITHDRAW for same-asset vault withdrawals on same chain', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: ASSET, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 1, - isUnstake: false, - ensoEnabled: true - }) - - expect(route).toBe('DIRECT_WITHDRAW') - }) - - it('returns ENSO for non-direct routes when Enso is enabled', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: OTHER, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 1, - isUnstake: false, - ensoEnabled: true - }) - - expect(route).toBe('ENSO') - }) - - it('returns DIRECT_WITHDRAW for non-direct routes when Enso is disabled', () => { - const route = resolveWithdrawRouteType({ - withdrawToken: OTHER, - assetAddress: ASSET, - withdrawalSource: 'vault', - chainId: 1, - outputChainId: 10, - isUnstake: false, - ensoEnabled: false - }) - - expect(route).toBe('DIRECT_WITHDRAW') - }) -}) diff --git a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts deleted file mode 100644 index efd0d051a..000000000 --- a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildWithdrawTransactionStep, - getWithdrawCtaLabel, - getWithdrawTransactionName, - isWithdrawCtaDisabled, - isWithdrawLastStep -} from './withdrawStepHelpers' - -const mockPrepare = { isSuccess: true, data: { request: {} } } as any - -describe('withdrawStepHelpers', () => { - it('returns transaction names for route types', () => { - expect(getWithdrawTransactionName('DIRECT_WITHDRAW', false)).toBe('Withdraw') - expect(getWithdrawTransactionName('DIRECT_UNSTAKE', false)).toBe('Unstake') - expect(getWithdrawTransactionName('DIRECT_UNSTAKE_WITHDRAW', false)).toBe('Unstake & Withdraw') - expect(getWithdrawTransactionName('ENSO', true)).toBe('Fetching quote') - }) - - it('builds approval step when approval is required', () => { - const step = buildWithdrawTransactionStep({ - needsApproval: true, - approvePrepare: mockPrepare, - activeWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'ENSO', - isCrossChain: false, - formattedApprovalAmount: '1.23', - formattedRequiredShares: '1.23', - formattedWithdrawAmount: '1.23', - approvalTokenSymbol: 'yvUSDC', - withdrawNotificationParams: undefined - }) - - expect(step?.label).toBe('Approve') - expect(step?.confirmMessage).toContain('Approving 1.23 yvUSDC') - }) - - it('builds unstake and withdraw fallback steps', () => { - const unstakeStep = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - directUnstakePrepare: mockPrepare, - directWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'DIRECT_UNSTAKE_WITHDRAW', - isCrossChain: false, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - stakingTokenSymbol: 'st-yvUSDC', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - const withdrawStep = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - directUnstakePrepare: mockPrepare, - directWithdrawPrepare: mockPrepare, - fallbackStep: 'withdraw', - routeType: 'DIRECT_UNSTAKE_WITHDRAW', - isCrossChain: false, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - stakingTokenSymbol: 'st-yvUSDC', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - expect(unstakeStep?.label).toBe('Unstake') - expect(withdrawStep?.label).toBe('Withdraw') - }) - - it('builds cross-chain success messaging for regular routes', () => { - const step = buildWithdrawTransactionStep({ - needsApproval: false, - activeWithdrawPrepare: mockPrepare, - fallbackStep: 'unstake', - routeType: 'ENSO', - isCrossChain: true, - formattedApprovalAmount: '1.00', - formattedRequiredShares: '2.00', - formattedWithdrawAmount: '3.00', - assetTokenSymbol: 'USDC', - withdrawNotificationParams: undefined - }) - - expect(step?.successTitle).toBe('Transaction Submitted') - }) - - it('computes last step state correctly', () => { - expect( - isWithdrawLastStep({ - currentStep: undefined, - needsApproval: false, - routeType: 'ENSO' - }) - ).toBe(true) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Approve', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: true, - routeType: 'ENSO' - }) - ).toBe(false) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Unstake', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: false, - routeType: 'DIRECT_UNSTAKE_WITHDRAW' - }) - ).toBe(false) - - expect( - isWithdrawLastStep({ - currentStep: { - prepare: mockPrepare, - label: 'Withdraw', - confirmMessage: '', - successTitle: '', - successMessage: '' - }, - needsApproval: false, - routeType: 'DIRECT_UNSTAKE_WITHDRAW' - }) - ).toBe(true) - }) - - it('computes CTA disabled state and label', () => { - expect( - isWithdrawCtaDisabled({ - hasError: false, - withdrawAmountRaw: 1n, - isFetchingQuote: false, - isDebouncing: false, - showApprove: true, - isAllowanceSufficient: false, - prepareApproveEnabled: false, - prepareWithdrawEnabled: true - }) - ).toBe(true) - - expect( - isWithdrawCtaDisabled({ - hasError: false, - withdrawAmountRaw: 1n, - isFetchingQuote: false, - isDebouncing: false, - showApprove: false, - isAllowanceSufficient: true, - prepareApproveEnabled: false, - prepareWithdrawEnabled: true - }) - ).toBe(false) - - expect( - getWithdrawCtaLabel({ - isFetchingQuote: false, - showApprove: true, - isAllowanceSufficient: false, - transactionName: 'Withdraw' - }) - ).toBe('Approve & Withdraw') - }) -}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx deleted file mode 100644 index 81f118bc2..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { scheduleAdditionalYvUsdDepositRefetch, shouldRefetchUnlockedAfterYvUsdDeposit } from './YvUsdDeposit.helpers' - -describe('YvUsdDeposit', () => { - beforeEach(() => { - vi.useRealTimers() - }) - - it('defers the unlocked branch refetch after a locked deposit success', () => { - vi.useFakeTimers() - const unlockedRefetch = vi.fn() - scheduleAdditionalYvUsdDepositRefetch('locked', unlockedRefetch) - - expect(unlockedRefetch).not.toHaveBeenCalled() - - vi.runAllTimers() - - expect(unlockedRefetch).toHaveBeenCalledTimes(1) - }) - - it('does not schedule an extra refetch after an unlocked deposit success', () => { - vi.useFakeTimers() - const unlockedRefetch = vi.fn() - scheduleAdditionalYvUsdDepositRefetch('unlocked', unlockedRefetch) - - vi.runAllTimers() - - expect(unlockedRefetch).not.toHaveBeenCalled() - }) - - it('only schedules the extra unlocked refetch for locked deposits', () => { - expect(shouldRefetchUnlockedAfterYvUsdDeposit('locked')).toBe(true) - expect(shouldRefetchUnlockedAfterYvUsdDeposit('unlocked')).toBe(false) - expect(shouldRefetchUnlockedAfterYvUsdDeposit(null)).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx deleted file mode 100644 index 739b6312b..000000000 --- a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { YvUsdApyDetailsContent } from './YvUsdBreakdown' - -describe('YvUsdApyDetailsContent', () => { - it('describes the locked withdrawal window as 5 days', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Withdrawals are open for 5 days once the cooldown ends.') - }) -}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx deleted file mode 100644 index 12bbc1379..000000000 --- a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { YVUSD_LEARN_MORE_URL } from '@pages/vaults/utils/yvUsd' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { YvUsdHeaderBanner } from './YvUsdHeaderBanner' - -describe('YvUsdHeaderBanner', () => { - it('renders the mockup copy, links, and shipped banner assets', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Transparent, Verifiable, Real Yield') - expect(html).toContain(`href="${YVUSD_LEARN_MORE_URL}"`) - expect(html).toContain('yvusd-banner-bg.png') - expect(html).toContain('Learn more') - expect(html).toContain('about Yearn's new cross-chain, cross-asset, delta-neutral vault.') - }) -}) diff --git a/src/components/pages/vaults/domain/normalizeVault.test.ts b/src/components/pages/vaults/domain/normalizeVault.test.ts deleted file mode 100644 index 80f46f6ad..000000000 --- a/src/components/pages/vaults/domain/normalizeVault.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getCanonicalHoldingsVaultAddress, - getHoldingsAliasVaultAddress, - YBOLD_STAKING_ADDRESS, - YBOLD_VAULT_ADDRESS -} from './normalizeVault' - -describe('holdings alias helpers', () => { - it('maps the yBOLD staking wrapper to the base vault', () => { - expect(getHoldingsAliasVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - expect(getCanonicalHoldingsVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - }) - - it('keeps non-aliased vaults canonicalized to themselves', () => { - expect(getHoldingsAliasVaultAddress(YBOLD_VAULT_ADDRESS)).toBeUndefined() - expect(getCanonicalHoldingsVaultAddress(YBOLD_VAULT_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) - }) -}) diff --git a/src/components/pages/vaults/domain/vaultWarnings.test.ts b/src/components/pages/vaults/domain/vaultWarnings.test.ts deleted file mode 100644 index 47b44c45d..000000000 --- a/src/components/pages/vaults/domain/vaultWarnings.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isNonYearnErc4626Vault, NON_YEARN_ERC4626_WARNING_MESSAGE } from './vaultWarnings' - -describe('NON_YEARN_ERC4626_WARNING_MESSAGE', () => { - it('matches the expected copy', () => { - expect(NON_YEARN_ERC4626_WARNING_MESSAGE).toBe( - 'This is a non-Yearn ERC-4626 Vault. Please be careful when interacting with it.' - ) - }) -}) - -describe('isNonYearnErc4626Vault', () => { - it('returns false for catalog Yearn vaults', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'yearn', - inclusion: { isYearn: true } - } as any - }) - ).toBe(false) - }) - - it('returns true when the list origin is missing', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: null, - inclusion: {} - } as any - }) - ).toBe(true) - }) - - it('returns true when the list origin is not yearn', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'partner', - inclusion: { isYearn: true } - } as any - }) - ).toBe(true) - }) - - it('returns true when inclusion explicitly marks the vault as non-Yearn', () => { - expect( - isNonYearnErc4626Vault({ - vault: { - origin: 'yearn', - inclusion: { isYearn: false } - } as any - }) - ).toBe(true) - }) - - it('returns true from snapshot metadata when list metadata is unavailable', () => { - expect( - isNonYearnErc4626Vault({ - snapshot: { - inclusion: { isYearn: false } - } as any - }) - ).toBe(true) - }) -}) diff --git a/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts b/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts deleted file mode 100644 index a79f2d770..000000000 --- a/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { erc4626Abi } from '@shared/contracts/abi/4626.abi' -import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' -import { TOKENIZED_STRATEGY_ABI } from '@shared/contracts/abi/tokenizedStrategy.abi' -import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' -import { describe, expect, it } from 'vitest' -import { - getDirectStakeCall, - getDirectUnstakeCalls, - getRedeemPreviewCall, - getStakePreviewCall, - getStakingWithdrawableAssets, - normalizeStakingSource -} from './stakingAdapter' - -describe('stakingAdapter', () => { - it('normalizes known and unknown staking sources', () => { - expect(normalizeStakingSource('VeYFI')).toBe('VeYFI') - expect(normalizeStakingSource('yBOLD')).toBe('yBOLD') - expect(normalizeStakingSource('Legacy')).toBe('default') - }) - - it('builds stake preview calls for source-specific ERC4626 staking', () => { - const amount = 42n - expect(getStakePreviewCall('VeYFI', amount)).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'previewDeposit', - args: [amount] - }) - expect(getStakePreviewCall('yBOLD', amount)).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'previewDeposit', - args: [amount] - }) - expect(getStakePreviewCall('Legacy', amount)).toBeUndefined() - }) - - it('builds redeem preview calls for vault-backed staking wrappers', () => { - const shares = 42n - expect(getRedeemPreviewCall('VeYFI', shares)).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'previewRedeem', - args: [shares] - }) - expect(getRedeemPreviewCall('yBOLD', shares)).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'previewRedeem', - args: [shares] - }) - expect(getRedeemPreviewCall('Legacy', shares)).toBeUndefined() - }) - - it('builds direct stake calls per staking source', () => { - const amount = 100n - const account = '0x1111111111111111111111111111111111111111' - - expect(getDirectStakeCall({ stakingSource: 'VeYFI', amount, account })).toMatchObject({ - abi: VEYFI_GAUGE_ABI, - functionName: 'deposit', - args: [amount] - }) - - expect(getDirectStakeCall({ stakingSource: 'yBOLD', amount, account })).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'deposit', - args: [amount, account] - }) - - expect(getDirectStakeCall({ stakingSource: 'Legacy', amount, account })).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'stake', - args: [amount] - }) - }) - - it('builds direct unstake calls with source-first + fallback behavior', () => { - const amount = 321n - const account = '0x1111111111111111111111111111111111111111' - - const yboldCalls = getDirectUnstakeCalls({ stakingSource: 'yBOLD', amount, account }) - expect(yboldCalls.primary).toMatchObject({ - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'withdraw', - args: [amount, account, account] - }) - expect(yboldCalls.fallback).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'withdraw', - args: [amount] - }) - - const defaultCalls = getDirectUnstakeCalls({ stakingSource: 'Legacy', amount, account }) - expect(defaultCalls.primary).toMatchObject({ - abi: STAKING_REWARDS_ABI, - functionName: 'withdraw', - args: [amount] - }) - expect(defaultCalls.fallback).toMatchObject({ - abi: erc4626Abi, - functionName: 'withdraw', - args: [amount, account, account] - }) - }) - - it('falls back to maxWithdraw when maxRedeem conversion is unavailable', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - const read = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') throw new Error('missing') - if (functionName === 'maxWithdraw') return 123n - return 0n - } - - const result = await getStakingWithdrawableAssets({ - read, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - - expect(result).toBe(123n) - }) - - it('prefers maxRedeem + convertToAssets for ERC4626 wrappers', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - const read = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') return 99n - if (functionName === 'convertToAssets') return 150n - if (functionName === 'maxWithdraw') return 123n - return 0n - } - - const result = await getStakingWithdrawableAssets({ - read, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - - expect(result).toBe(150n) - }) - - it('falls back to convertToAssets and then raw balance for withdrawable assets', async () => { - const account = '0x1111111111111111111111111111111111111111' - const stakingAddress = '0x2222222222222222222222222222222222222222' - - const convertRead = async ({ - functionName - }: { - functionName: string - address: `0x${string}` - abi: readonly unknown[] - args?: readonly unknown[] - }) => { - if (functionName === 'maxRedeem') { - throw new Error('missing') - } - if (functionName === 'maxWithdraw') { - throw new Error('missing') - } - if (functionName === 'convertToAssets') { - return 456n - } - return 0n - } - - const converted = await getStakingWithdrawableAssets({ - read: convertRead, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - expect(converted).toBe(456n) - - const failingRead = async () => { - throw new Error('missing') - } - const fallback = await getStakingWithdrawableAssets({ - read: failingRead, - stakingAddress, - account, - stakingSource: 'yBOLD', - stakingShareBalance: 99n - }) - expect(fallback).toBe(99n) - }) -}) diff --git a/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts b/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts deleted file mode 100644 index 2e4609ad8..000000000 --- a/src/components/pages/vaults/hooks/useEnsureVaultListFetch.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { shouldEnableVaultListFetch } from '@pages/vaults/hooks/useEnsureVaultListFetch' -import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' -import { describe, expect, it } from 'vitest' - -describe('shouldEnableVaultListFetch', () => { - it('returns false for direct detail loads until snapshot metadata is available', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: false, - snapshotVault: undefined - }) - ).toBe(false) - }) - - it('returns true once snapshot metadata is available for a direct detail load', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: false, - snapshotVault: { address: '0x0000000000000000000000000000000000000001' } as unknown as TKongVaultSnapshot - }) - ).toBe(true) - }) - - it('returns false after the vault list bootstrap has already been triggered', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: true, - hasVaultList: false, - isYvUsd: false, - snapshotVault: { address: '0x0000000000000000000000000000000000000001' } as unknown as TKongVaultSnapshot - }) - ).toBe(false) - }) - - it('returns true for yvUSD detail routes even before snapshot metadata arrives', () => { - expect( - shouldEnableVaultListFetch({ - hasTriggeredVaultListFetch: false, - hasVaultList: false, - isYvUsd: true, - snapshotVault: undefined - }) - ).toBe(true) - }) -}) diff --git a/src/components/pages/vaults/utils/blockingFilterInsights.test.ts b/src/components/pages/vaults/utils/blockingFilterInsights.test.ts deleted file mode 100644 index f5f53da05..000000000 --- a/src/components/pages/vaults/utils/blockingFilterInsights.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - getAdditionalResultsForCombo, - getCommonBlockingKeys, - shouldShowComboBlockingAction -} from './blockingFilterInsights' - -type TKey = 'showAllChains' | 'showAllVaults' | 'clearMinTvl' | 'showAllCategories' | 'showStrategies' - -describe('blockingFilterInsights', () => { - it('derives common blockers and combo additions from combo-applied filters', () => { - const hiddenBlockingKeys: TKey[][] = [ - ['showAllVaults', 'showAllChains'], - ['showAllVaults', 'showAllChains', 'clearMinTvl'], - ['showAllVaults', 'showAllChains', 'showAllCategories'] - ] - - const comboKeys = getCommonBlockingKeys(hiddenBlockingKeys) - const comboAdditionalResults = getAdditionalResultsForCombo(hiddenBlockingKeys, comboKeys) - - expect(comboKeys).toEqual(['showAllVaults', 'showAllChains']) - expect(comboAdditionalResults).toBe(1) - }) - - it('does not count hidden entries without detected blockers', () => { - const comboAdditionalResults = getAdditionalResultsForCombo( - [[], ['showAllChains'], ['showAllChains', 'clearMinTvl']], - ['showAllChains'] - ) - - expect(comboAdditionalResults).toBe(1) - }) - - it('shows fallback combo action for a single common blocker when no individual actions are available', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 3, - comboKeys: ['showAllChains'], - actionableKeys: new Set() - }) - - expect(shouldShow).toBe(true) - }) - - it('hides combo action when hidden results do not exist', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 0, - comboKeys: ['showAllChains'], - actionableKeys: new Set() - }) - - expect(shouldShow).toBe(false) - }) - - it('hides combo action when all combo keys are already individually actionable', () => { - const shouldShow = shouldShowComboBlockingAction({ - hiddenByFiltersCount: 4, - comboKeys: ['showAllChains', 'showStrategies'], - actionableKeys: new Set(['showAllChains', 'showStrategies']) - }) - - expect(shouldShow).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/utils/chainSelection.test.ts b/src/components/pages/vaults/utils/chainSelection.test.ts deleted file mode 100644 index 66c581a62..000000000 --- a/src/components/pages/vaults/utils/chainSelection.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveNextSingleChainSelection } from './chainSelection' - -describe('resolveNextSingleChainSelection', () => { - it('selects the requested chain when nothing is selected', () => { - expect(resolveNextSingleChainSelection(null, 1)).toEqual([1]) - expect(resolveNextSingleChainSelection([], 10)).toEqual([10]) - }) - - it('switches directly to the requested chain', () => { - expect(resolveNextSingleChainSelection([1], 10)).toEqual([10]) - expect(resolveNextSingleChainSelection([1, 10], 42161)).toEqual([42161]) - }) - - it('clears the filter when reselecting the only active chain', () => { - expect(resolveNextSingleChainSelection([1], 1)).toBeNull() - }) -}) diff --git a/src/components/pages/vaults/utils/vaultLogo.test.ts b/src/components/pages/vaults/utils/vaultLogo.test.ts deleted file mode 100644 index 685695f12..000000000 --- a/src/components/pages/vaults/utils/vaultLogo.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' -import { getVaultPrimaryLogoSrc } from './vaultLogo' -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from './yvUsd' - -const STANDARD_VAULT = { - version: '3.0.0', - chainID: 1, - address: '0x0000000000000000000000000000000000000001', - token: { - address: '0x0000000000000000000000000000000000000002', - symbol: 'TKN', - decimals: 18 - } -} - -describe('getVaultPrimaryLogoSrc', () => { - it('returns the yvUSD seal logo for the unlocked vault', () => { - expect( - getVaultPrimaryLogoSrc({ - ...STANDARD_VAULT, - address: YVUSD_UNLOCKED_ADDRESS - } as unknown as TKongVaultInput) - ).toMatch(/yvUSD-seal\.png$/) - }) - - it('returns the yvUSD seal logo for the locked vault', () => { - expect( - getVaultPrimaryLogoSrc({ - ...STANDARD_VAULT, - address: YVUSD_LOCKED_ADDRESS - } as unknown as TKongVaultInput) - ).toMatch(/yvUSD-seal\.png$/) - }) - - it('returns the standard token asset logo for non-yvUSD vaults', () => { - expect(getVaultPrimaryLogoSrc(STANDARD_VAULT as unknown as TKongVaultInput)).toContain('logo-128.png') - }) -}) diff --git a/src/components/shared/components/Tooltip.test.tsx b/src/components/shared/components/Tooltip.test.tsx deleted file mode 100644 index 4a22987d2..000000000 --- a/src/components/shared/components/Tooltip.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom -import { fireEvent, render } from '@testing-library/react' -import { act } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { Tooltip } from './Tooltip' - -describe('Tooltip', () => { - it('delays opening when openDelayMs is set', () => { - vi.useFakeTimers() - const { container, queryByText } = render( - Tip
} openDelayMs={150}> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.mouseEnter(trigger) - expect(queryByText('Tip')).toBeNull() - - act(() => { - vi.advanceTimersByTime(150) - }) - expect(queryByText('Tip')).not.toBeNull() - vi.useRealTimers() - }) - - it('toggles on click when toggleOnClick is true', () => { - const { container, queryByText } = render( - Tip
} toggleOnClick> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.click(trigger) - expect(queryByText('Tip')).not.toBeNull() - fireEvent.click(trigger) - expect(queryByText('Tip')).toBeNull() - }) - - it('does not open on hover on devices without hover support', () => { - const originalMatchMedia = window.matchMedia - Object.defineProperty(window, 'matchMedia', { - writable: true, - configurable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn() - })) - }) - - try { - const { container, queryByText } = render( - Tip
}> - - - ) - - const trigger = container.firstChild as HTMLElement - fireEvent.mouseEnter(trigger) - expect(queryByText('Tip')).toBeNull() - } finally { - Object.defineProperty(window, 'matchMedia', { - writable: true, - configurable: true, - value: originalMatchMedia - }) - } - }) -}) diff --git a/src/components/shared/components/YearnApps.test.ts b/src/components/shared/components/YearnApps.test.ts deleted file mode 100644 index 906bd019d..000000000 --- a/src/components/shared/components/YearnApps.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { APP_GROUPS } from './YearnApps' - -describe('APP_GROUPS', () => { - const allNames = APP_GROUPS.flatMap((group) => group.items.map((item) => item.name)) - - it('includes newly requested internal tools', () => { - expect(allNames).toEqual( - expect.arrayContaining(['YearnX', 'APR Oracle', 'yCMS', 'Token Assets', 'Seafood', 'Kong', 'PowerGlove']) - ) - }) - - it('includes resource links for community navigation', () => { - expect(allNames).toEqual(expect.arrayContaining(['Docs', 'Support', 'Blog', 'Discourse'])) - }) - - it('exposes both v3 and LP vault entrypoints', () => { - expect(allNames).toEqual(expect.arrayContaining(['V3 Vaults', 'LP Vaults'])) - }) -}) diff --git a/src/components/shared/contexts/useChartStyle.test.ts b/src/components/shared/contexts/useChartStyle.test.ts deleted file mode 100644 index 22f9ec665..000000000 --- a/src/components/shared/contexts/useChartStyle.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CHART_STYLE_OPTIONS, getChartStyleVariables } from '@shared/utils/chartStyles' -import { describe, expect, it } from 'vitest' - -describe('chart styles', () => { - it('exposes minimal and powerglove options', () => { - expect(CHART_STYLE_OPTIONS.map((o) => o.id)).toEqual(expect.arrayContaining(['minimal', 'blended', 'powerglove'])) - }) - - it('provides a palette for each style', () => { - const minimal = getChartStyleVariables('minimal') - const powerglove = getChartStyleVariables('powerglove') - - expect(minimal['--chart-1']).toBeTruthy() - expect(minimal['--chart-2']).toBeTruthy() - expect(minimal['--chart-3']).toBeTruthy() - expect(minimal['--chart-4']).toBeTruthy() - expect(powerglove['--chart-1']).toBeTruthy() - expect(powerglove['--chart-2']).toBeTruthy() - expect(powerglove['--chart-3']).toBeTruthy() - expect(powerglove['--chart-4']).toBeTruthy() - }) -}) diff --git a/src/components/shared/contexts/useNotifications.helpers.test.ts b/src/components/shared/contexts/useNotifications.helpers.test.ts deleted file mode 100644 index 93e5bd0b0..000000000 --- a/src/components/shared/contexts/useNotifications.helpers.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { appendCachedNotification, mergeCachedNotificationEntry } from './useNotifications.helpers' - -describe('useNotifications.helpers', () => { - it('appends new notifications without reloading the existing list', () => { - const first = { - id: 1, - type: 'deposit' as const, - address: '0x111' as const, - chainId: 1, - amount: '1', - status: 'pending' as const - } - const second = { - id: 2, - type: 'withdraw' as const, - address: '0x222' as const, - chainId: 1, - amount: '2', - status: 'pending' as const - } - - expect(appendCachedNotification([first], second)).toEqual([first, second]) - }) - - it('merges notification updates in place', () => { - const original = { - id: 7, - type: 'deposit' as const, - address: '0x111' as const, - chainId: 1, - amount: '1', - status: 'pending' as const - } - - expect(mergeCachedNotificationEntry([original], 7, { status: 'success', txHash: '0xabc' })).toEqual([ - { - ...original, - status: 'success', - txHash: '0xabc' - } - ]) - }) - - it('adds the updated notification if the cache does not contain it yet', () => { - expect( - mergeCachedNotificationEntry([], 3, { - type: 'deposit', - address: '0x333', - chainId: 1, - amount: '3', - status: 'success' - }) - ).toEqual([ - { - id: 3, - type: 'deposit', - address: '0x333', - chainId: 1, - amount: '3', - status: 'success' - } - ]) - }) -}) diff --git a/src/components/shared/contexts/useWallet.helpers.test.ts b/src/components/shared/contexts/useWallet.helpers.test.ts deleted file mode 100644 index e46ca3bd8..000000000 --- a/src/components/shared/contexts/useWallet.helpers.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { zeroNormalizedBN } from '../utils' -import { hasWalletBalanceSnapshot, shouldExposeWalletLoading } from './useWallet.helpers' - -describe('useWallet.helpers', () => { - it('detects when the wallet has a settled balance snapshot', () => { - expect(hasWalletBalanceSnapshot({})).toBe(false) - expect(hasWalletBalanceSnapshot({ 1: {} })).toBe(false) - expect( - hasWalletBalanceSnapshot({ - 1: { - '0x123': { - address: '0x123', - name: 'Token', - symbol: 'TOK', - decimals: 18, - chainID: 1, - value: 0, - balance: zeroNormalizedBN - } - } - }) - ).toBe(true) - }) - - it('exposes loading only for cold wallet loads', () => { - expect( - shouldExposeWalletLoading({ - userAddress: undefined, - hasVisibleBalances: false, - isLoading: true, - isBalancesPending: true - }) - ).toBe(false) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: false, - isLoading: true, - isBalancesPending: false - }) - ).toBe(true) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: false, - isLoading: false, - isBalancesPending: true - }) - ).toBe(true) - }) - - it('keeps background refreshes visually quiet once balances are visible', () => { - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: true, - isLoading: true, - isBalancesPending: false - }) - ).toBe(false) - - expect( - shouldExposeWalletLoading({ - userAddress: '0x123', - hasVisibleBalances: true, - isLoading: false, - isBalancesPending: true - }) - ).toBe(false) - }) -}) diff --git a/src/components/shared/hooks/useBalancesCombined.test.ts b/src/components/shared/hooks/useBalancesCombined.test.ts deleted file mode 100644 index 5856c8def..000000000 --- a/src/components/shared/hooks/useBalancesCombined.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { shouldUseDiscoveryFallbackToken } from './balanceDiscoveryFallback' - -describe('shouldUseDiscoveryFallbackToken', () => { - it('uses discovery fallback for staking tokens', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isStakingToken: true }, - hasPositiveBalanceCache: false - }) - ).toBe(true) - }) - - it('skips discovery fallback for non-catalog vault shares without other signals', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: false }, - hasPositiveBalanceCache: false - }) - ).toBe(false) - }) - - it('uses discovery fallback for previously positive cached balances', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, - hasPositiveBalanceCache: true - }) - ).toBe(true) - }) - - it('skips discovery fallback for ordinary omitted catalog vault shares', () => { - expect( - shouldUseDiscoveryFallbackToken({ - token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, - hasPositiveBalanceCache: false - }) - ).toBe(false) - }) -}) diff --git a/src/components/shared/hooks/useBalancesQueries.test.ts b/src/components/shared/hooks/useBalancesQueries.test.ts deleted file mode 100644 index ec2db5f13..000000000 --- a/src/components/shared/hooks/useBalancesQueries.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import type { TChainTokens } from '../types/mixed' -import type { TUseBalancesTokens } from './useBalances.multichains' -import { mergeStagedQueryData, partitionTokensByQueryStage } from './useBalancesQueries.helpers' - -const CHAIN1_TOKEN_A = '0x1111111111111111111111111111111111111111' -const CHAIN1_TOKEN_B = '0x2222222222222222222222222222222222222222' -const CHAIN10_TOKEN = '0x3333333333333333333333333333333333333333' -const CHAIN250_TOKEN = '0x4444444444444444444444444444444444444444' - -function normalizedBalance(raw: bigint, normalized: number, decimals: number = 18) { - return { - raw, - normalized, - display: normalized.toString(), - decimals - } -} - -function tokenKey(token: TUseBalancesTokens): string { - return `${token.chainID}:${getAddress(token.address)}` -} - -describe('partitionTokensByQueryStage', () => { - it('dedupes tokens and keeps the priority chain separate', () => { - const tokens: TUseBalancesTokens[] = [ - { address: CHAIN10_TOKEN, chainID: 10 }, - { address: CHAIN1_TOKEN_A, chainID: 1 }, - { address: CHAIN1_TOKEN_A, chainID: 1, symbol: 'dup' }, - { address: CHAIN1_TOKEN_B, chainID: 1 }, - { address: CHAIN250_TOKEN, chainID: 250 } - ] - - const { priorityTokensByChain, secondaryTokensByChain } = partitionTokensByQueryStage(tokens, 1) - - expect(priorityTokensByChain[1]?.map(tokenKey)).toEqual([ - `1:${getAddress(CHAIN1_TOKEN_A)}`, - `1:${getAddress(CHAIN1_TOKEN_B)}` - ]) - expect(secondaryTokensByChain[10]?.map(tokenKey)).toEqual([`10:${getAddress(CHAIN10_TOKEN)}`]) - expect(secondaryTokensByChain[250]?.map(tokenKey)).toEqual([`250:${getAddress(CHAIN250_TOKEN)}`]) - }) - - it('keeps all chains in the secondary stage when the priority chain is absent', () => { - const tokens: TUseBalancesTokens[] = [ - { address: CHAIN10_TOKEN, chainID: 10 }, - { address: CHAIN250_TOKEN, chainID: 250 } - ] - - const { priorityTokensByChain, secondaryTokensByChain } = partitionTokensByQueryStage(tokens, 1) - - expect(priorityTokensByChain).toEqual({}) - expect(secondaryTokensByChain[10]?.map(tokenKey)).toEqual([`10:${getAddress(CHAIN10_TOKEN)}`]) - expect(secondaryTokensByChain[250]?.map(tokenKey)).toEqual([`250:${getAddress(CHAIN250_TOKEN)}`]) - }) -}) - -describe('mergeStagedQueryData', () => { - it('reuses the previous object when staged query data is deep-equal', () => { - const previousData: TChainTokens = { - 1: { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - }, - 10: { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - } - - const nextData = mergeStagedQueryData({ - previousData, - priorityChainIds: [1], - priorityQueryData: [ - { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - } - ], - secondaryChainIds: [10], - secondaryQueryData: [ - { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - ] - }) - - expect(nextData).toBe(previousData) - }) - - it('preserves unchanged chain branches when only one staged chain changes', () => { - const previousData: TChainTokens = { - 1: { - [getAddress(CHAIN1_TOKEN_A)]: { - address: getAddress(CHAIN1_TOKEN_A), - chainID: 1, - symbol: 'AAA', - name: 'Token AAA', - decimals: 18, - value: 1, - balance: normalizedBalance(1n, 1) - } - }, - 10: { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 2, - balance: normalizedBalance(2n, 2) - } - } - } - - const nextData = mergeStagedQueryData({ - previousData, - priorityChainIds: [1], - priorityQueryData: [previousData[1]], - secondaryChainIds: [10], - secondaryQueryData: [ - { - [getAddress(CHAIN10_TOKEN)]: { - address: getAddress(CHAIN10_TOKEN), - chainID: 10, - symbol: 'OP', - name: 'Optimism', - decimals: 18, - value: 3, - balance: normalizedBalance(3n, 3) - } - } - ] - }) - - expect(nextData).not.toBe(previousData) - expect(nextData[1]).toBe(previousData[1]) - expect(nextData[10]).not.toBe(previousData[10]) - }) -}) diff --git a/src/components/shared/hooks/useBalancesQuery.test.ts b/src/components/shared/hooks/useBalancesQuery.test.ts deleted file mode 100644 index d223260b9..000000000 --- a/src/components/shared/hooks/useBalancesQuery.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -describe('balanceQueryKeys', () => { - afterEach(() => { - vi.resetModules() - vi.doUnmock('../utils/tools.address') - }) - - it('separates cache keys by execution chain id', async () => { - vi.doMock('../utils/tools.address', () => ({ - toAddress: (value?: string) => value?.toLowerCase() ?? '0x0' - })) - - const { balanceQueryKeys } = await import('./useBalancesQuery') - - expect(balanceQueryKeys.byTokens(1, 73571, '0x123', ['0xabc'])).not.toEqual( - balanceQueryKeys.byTokens(1, 73572, '0x123', ['0xabc']) - ) - }) - - it('keeps canonical chain id in the key for display-level grouping', async () => { - vi.doMock('../utils/tools.address', () => ({ - toAddress: (value?: string) => value?.toLowerCase() ?? '0x0' - })) - - const { balanceQueryKeys } = await import('./useBalancesQuery') - - expect(balanceQueryKeys.byChain(1, 73571)).toEqual(['balances', 'chain', 1, 'execution', 73571]) - }) -}) diff --git a/src/components/shared/hooks/useBalancesRouting.test.ts b/src/components/shared/hooks/useBalancesRouting.test.ts deleted file mode 100644 index 80ffc34e6..000000000 --- a/src/components/shared/hooks/useBalancesRouting.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { getAddress } from 'viem' -import { describe, expect, it } from 'vitest' -import type { TUseBalancesTokens } from './useBalances.multichains' -import { partitionTokensByBalanceSource } from './useBalancesRouting' - -const VAULT_A = '0x1111111111111111111111111111111111111111' -const STAKING_A = '0x2222222222222222222222222222222222222222' -const VAULT_B = '0x3333333333333333333333333333333333333333' -const STAKING_B = '0x4444444444444444444444444444444444444444' -const VAULT_C = '0x5555555555555555555555555555555555555555' - -function tokenKey(token: TUseBalancesTokens): string { - return `${token.chainID}:${getAddress(token.address)}` -} - -describe('partitionTokensByBalanceSource', () => { - it('routes staking-only pair tokens to multicall', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_A, - chainID: 1, - for: 'vault', - isVaultToken: true, - isStakingOnlyPair: true, - pairedStakingAddress: STAKING_A - }, - { - address: STAKING_A, - chainID: 1, - for: 'staking', - isStakingToken: true, - isStakingOnlyPair: true, - pairedVaultAddress: VAULT_A - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_A)}`, `1:${getAddress(STAKING_A)}`]) - }) - - it('routes vault-backed staking tokens to enso on supported chains', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_B, - chainID: 1, - for: 'vault', - isVaultToken: true, - isVaultBackedStaking: true, - pairedStakingAddress: STAKING_B - }, - { - address: STAKING_B, - chainID: 1, - for: 'staking', - isVaultToken: true, - isStakingToken: true, - isVaultBackedStaking: true, - pairedVaultAddress: VAULT_B - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - expect(multicallTokens).toHaveLength(0) - expect(ensoTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`, `1:${getAddress(STAKING_B)}`]) - }) - - it('routes unsupported chains to multicall even for vault-backed staking', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: STAKING_B, - chainID: 250, - for: 'staking', - isStakingToken: true, - isVaultBackedStaking: true - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [250]) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`250:${getAddress(STAKING_B)}`]) - }) - - it('routes Tenderly-backed canonical chains to multicall when Enso is disabled for them', () => { - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_B, - chainID: 1, - for: 'vault', - isVaultToken: true - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [1]) - expect(ensoTokens).toHaveLength(0) - expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`]) - }) - - it('dedupes duplicate entries and never routes same token to both sources', () => { - const tokens: TUseBalancesTokens[] = [ - { address: VAULT_A, chainID: 1, for: 'vault' }, - { address: VAULT_A, chainID: 1 }, - { address: VAULT_C, chainID: 1, for: 'vault', isStakingOnlyPair: true, pairedStakingAddress: STAKING_A }, - { address: STAKING_A, chainID: 1, for: 'staking' }, - { address: STAKING_A, chainID: 1, isStakingToken: true, isStakingOnlyPair: true, pairedVaultAddress: VAULT_C } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - const ensoKeys = new Set(ensoTokens.map(tokenKey)) - const multicallKeys = new Set(multicallTokens.map(tokenKey)) - const overlap = [...ensoKeys].filter((key) => multicallKeys.has(key)) - - expect(overlap).toHaveLength(0) - expect([...ensoKeys]).toEqual([`1:${getAddress(VAULT_A)}`]) - expect([...multicallKeys]).toEqual([`1:${getAddress(VAULT_C)}`, `1:${getAddress(STAKING_A)}`]) - }) - - it('preserves discovery metadata when duplicate token entries are merged', () => { - const aliasVault = getAddress(VAULT_B) - const tokens: TUseBalancesTokens[] = [ - { - address: VAULT_A, - chainID: 1, - for: 'vault', - isCatalogVault: true - }, - { - address: VAULT_A, - chainID: 1, - isCatalogVault: false, - holdingsAliasVaultAddress: aliasVault - } - ] - - const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) - - expect(multicallTokens).toHaveLength(0) - expect(ensoTokens).toHaveLength(1) - expect(ensoTokens[0].isCatalogVault).toBe(false) - expect(ensoTokens[0].holdingsAliasVaultAddress).toBe(aliasVault) - }) -}) diff --git a/src/components/shared/hooks/useFetchYearnVaults.test.ts b/src/components/shared/hooks/useFetchYearnVaults.test.ts deleted file mode 100644 index 78d7c8fa4..000000000 --- a/src/components/shared/hooks/useFetchYearnVaults.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' -import { describe, expect, it } from 'vitest' -import { isCatalogYearnVault } from './useFetchYearnVaults' - -function makeVault(overrides: Partial): TKongVaultListItem { - return { - address: '0x1111111111111111111111111111111111111111', - chainId: 1, - origin: 'yearn', - inclusion: undefined, - token: { - address: '0x2222222222222222222222222222222222222222', - name: 'Token', - symbol: 'TKN', - decimals: 18 - }, - staking: undefined, - metadata: { - protocols: [] - }, - ...overrides - } as TKongVaultListItem -} - -describe('isCatalogYearnVault', () => { - it('keeps yearn vaults in the public catalog by default', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'yearn' }))).toBe(true) - }) - - it('excludes explicitly non-yearn catalog entries', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'partner', inclusion: { isYearn: true } as never }))).toBe(false) - }) - - it('excludes yearn vaults that Kong marks as not included', () => { - expect(isCatalogYearnVault(makeVault({ origin: 'yearn', inclusion: { isYearn: false } as never }))).toBe(false) - }) -}) diff --git a/src/components/shared/utils/curveUrlUtils.test.ts b/src/components/shared/utils/curveUrlUtils.test.ts deleted file mode 100644 index dddf9bc9f..000000000 --- a/src/components/shared/utils/curveUrlUtils.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isCurveHostUrl, normalizeCurveUrl } from './curveUrlUtils' - -describe('normalizeCurveUrl', () => { - it('canonicalizes curve.fi to www.curve.finance', () => { - expect(normalizeCurveUrl('https://curve.fi/pools')).toBe('https://www.curve.finance/pools') - }) - - it('canonicalizes curve.finance to www.curve.finance', () => { - expect(normalizeCurveUrl('https://curve.finance/pools')).toBe('https://www.curve.finance/pools') - }) - - it('keeps canonical host unchanged', () => { - expect(normalizeCurveUrl('https://www.curve.finance/pools')).toBe('https://www.curve.finance/pools') - }) - - it('returns empty string for invalid urls', () => { - expect(normalizeCurveUrl('not-a-url')).toBe('') - }) - - it('returns empty string for non-http schemes', () => { - expect(normalizeCurveUrl('javascript://curve.fi/%0Aalert(1)')).toBe('') - expect(normalizeCurveUrl('ftp://curve.fi/pools')).toBe('') - }) -}) - -describe('isCurveHostUrl', () => { - it('accepts allowed curve hosts', () => { - expect(isCurveHostUrl('https://curve.fi/pools')).toBe(true) - expect(isCurveHostUrl('https://www.curve.fi/pools')).toBe(true) - expect(isCurveHostUrl('https://curve.finance/pools')).toBe(true) - expect(isCurveHostUrl('https://www.curve.finance/pools')).toBe(true) - }) - - it('rejects non-curve hosts and invalid urls', () => { - expect(isCurveHostUrl('https://example.com/pools')).toBe(false) - expect(isCurveHostUrl('not-a-url')).toBe(false) - }) - - it('rejects non-http schemes even for allowed curve hosts', () => { - expect(isCurveHostUrl('javascript://curve.fi/%0Aalert(1)')).toBe(false) - expect(isCurveHostUrl('ftp://www.curve.finance/pools')).toBe(false) - }) -}) diff --git a/src/components/shared/utils/routes.test.ts b/src/components/shared/utils/routes.test.ts deleted file mode 100644 index 19c3ec3f1..000000000 --- a/src/components/shared/utils/routes.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isVaultsIndexPath, normalizePathname } from './routes' - -describe('normalizePathname', () => { - it('keeps root pathname', () => { - expect(normalizePathname('/')).toBe('/') - }) - - it('removes trailing slashes', () => { - expect(normalizePathname('/vaults/')).toBe('/vaults') - expect(normalizePathname('/vaults///')).toBe('/vaults') - }) -}) - -describe('isVaultsIndexPath', () => { - it('matches only /vaults (after normalization)', () => { - expect(isVaultsIndexPath('/vaults')).toBe(true) - expect(isVaultsIndexPath('/vaults/')).toBe(true) - expect(isVaultsIndexPath('/vaults/1/0x0')).toBe(false) - expect(isVaultsIndexPath('/vaults-beta')).toBe(false) - }) -}) diff --git a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts b/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts deleted file mode 100644 index 26a0325bd..000000000 --- a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { kongVaultSnapshotSchema } from './kongVaultSnapshotSchema' - -describe('kongVaultSnapshotSchema', () => { - it('retains Katana estimated component fields', () => { - const parsed = kongVaultSnapshotSchema.parse({ - address: '0x0000000000000000000000000000000000000001', - chainId: 747474, - performance: { - estimated: { - apr: 0.4687, - apy: 0.068, - type: 'katana-estimated-apr', - components: { - netAPR: 0.4687, - netAPY: 0.068, - katanaBonusAPY: 0.068, - katanaNativeYield: 0.027, - katanaAppRewardsAPR: 0.0916, - steerPointsPerDollar: 0.1883, - fixedRateKatanaRewards: 0.35, - FixedRateKatanaRewards: 0.36 - } - } - } - }) - - expect(parsed.performance?.estimated?.components?.netAPR).toBe(0.4687) - expect(parsed.performance?.estimated?.components?.netAPY).toBe(0.068) - expect(parsed.performance?.estimated?.components?.katanaBonusAPY).toBe(0.068) - expect(parsed.performance?.estimated?.components?.katanaNativeYield).toBe(0.027) - expect(parsed.performance?.estimated?.components?.katanaAppRewardsAPR).toBe(0.0916) - expect(parsed.performance?.estimated?.components?.steerPointsPerDollar).toBe(0.1883) - expect(parsed.performance?.estimated?.components?.fixedRateKatanaRewards).toBe(0.35) - expect(parsed.performance?.estimated?.components?.FixedRateKatanaRewards).toBe(0.36) - }) -}) diff --git a/src/components/shared/utils/tenderlyPanel.test.ts b/src/components/shared/utils/tenderlyPanel.test.ts deleted file mode 100644 index ed5163552..000000000 --- a/src/components/shared/utils/tenderlyPanel.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import type { TDict, TToken } from '@shared/types' -import type { TTenderlyConfiguredChainStatus, TTenderlySnapshotRecord } from '@shared/types/tenderly' -import { describe, expect, it } from 'vitest' -import { - addTenderlyTimeIncrement, - buildTenderlyFundableAssets, - clearTenderlySnapshotBucket, - convertTenderlyTimeAmountToSeconds, - getDefaultTenderlyFundableAssets, - getLastRestorableTenderlySnapshot, - getValidBaselineSnapshot, - markTenderlySnapshotInvalid, - reconcileTenderlySnapshotStorageAfterRevert, - resolveDefaultTenderlyCanonicalChainId, - upsertTenderlySnapshotRecord -} from './tenderlyPanel' - -const configuredChains: TTenderlyConfiguredChainStatus[] = [ - { - canonicalChainId: 1, - canonicalChainName: 'Ethereum', - executionChainId: 694201, - hasAdminRpc: true - }, - { - canonicalChainId: 10, - canonicalChainName: 'Optimism', - executionChainId: 694202, - hasAdminRpc: false - } -] - -const baselineSnapshot: TTenderlySnapshotRecord = { - snapshotId: '0xbaseline', - canonicalChainId: 1, - executionChainId: 694201, - label: 'Baseline', - createdAt: '2026-03-16T00:00:00.000Z', - kind: 'baseline', - lastKnownStatus: 'valid' -} - -describe('resolveDefaultTenderlyCanonicalChainId', () => { - it('prefers the first preferred chain that has admin RPC access', () => { - expect(resolveDefaultTenderlyCanonicalChainId(configuredChains, [10, 1])).toBe(1) - }) - - it('falls back to the first available configured chain', () => { - expect(resolveDefaultTenderlyCanonicalChainId(configuredChains, [8453])).toBe(1) - }) -}) - -describe('snapshot helpers', () => { - it('replaces prior baseline snapshots for the same chain bucket', () => { - const updatedStorage = upsertTenderlySnapshotRecord( - { - '1:694201': [baselineSnapshot] - }, - { - ...baselineSnapshot, - snapshotId: '0xbaseline2', - createdAt: '2026-03-17T00:00:00.000Z' - } - ) - - expect(updatedStorage['1:694201']).toHaveLength(1) - expect(updatedStorage['1:694201']?.[0]?.snapshotId).toBe('0xbaseline2') - }) - - it('marks a snapshot invalid and removes it as the valid baseline', () => { - const updatedStorage = markTenderlySnapshotInvalid( - { - '1:694201': [baselineSnapshot] - }, - { - canonicalChainId: 1, - executionChainId: 694201, - snapshotId: '0xbaseline' - } - ) - - expect(updatedStorage['1:694201']?.[0]?.lastKnownStatus).toBe('invalid') - expect(getValidBaselineSnapshot(updatedStorage['1:694201'] || [])).toBeUndefined() - }) - - it('clears local snapshot history for one chain bucket without touching others', () => { - const snapshotStorage = { - '1:694201': [baselineSnapshot], - '8453:69428453': [ - { - ...baselineSnapshot, - canonicalChainId: 8453, - executionChainId: 69428453, - snapshotId: '0xbase', - label: 'Base baseline' - } - ] - } - - const updatedStorage = clearTenderlySnapshotBucket(snapshotStorage, { - canonicalChainId: 1, - executionChainId: 694201 - }) - - expect(updatedStorage['1:694201']).toBeUndefined() - expect(updatedStorage['8453:69428453']).toEqual(snapshotStorage['8453:69428453']) - }) - - it('returns the latest valid snapshot before falling back to baseline', () => { - const latestSnapshot: TTenderlySnapshotRecord = { - ...baselineSnapshot, - snapshotId: '0xsnapshot-latest', - label: 'Latest snapshot', - kind: 'snapshot', - createdAt: '2026-03-18T00:00:00.000Z' - } - const olderSnapshot: TTenderlySnapshotRecord = { - ...baselineSnapshot, - snapshotId: '0xsnapshot-older', - label: 'Older snapshot', - kind: 'snapshot', - createdAt: '2026-03-17T00:00:00.000Z' - } - - expect(getLastRestorableTenderlySnapshot([baselineSnapshot, olderSnapshot, latestSnapshot])?.snapshotId).toBe( - '0xsnapshot-latest' - ) - expect( - getLastRestorableTenderlySnapshot([{ ...latestSnapshot, lastKnownStatus: 'invalid' }, baselineSnapshot]) - ?.snapshotId - ).toBe('0xbaseline') - }) - - it('invalidates spent snapshots and stores a replacement snapshot after revert', () => { - const revertedSnapshot: TTenderlySnapshotRecord = { - ...baselineSnapshot, - snapshotId: '0xsnapshot-current', - label: 'Current snapshot', - kind: 'snapshot', - createdAt: '2026-03-18T00:00:00.000Z' - } - const descendantSnapshot: TTenderlySnapshotRecord = { - ...baselineSnapshot, - snapshotId: '0xsnapshot-descendant', - label: 'Descendant snapshot', - kind: 'snapshot', - createdAt: '2026-03-19T00:00:00.000Z' - } - const replacementSnapshot: TTenderlySnapshotRecord = { - ...revertedSnapshot, - snapshotId: '0xsnapshot-replacement', - label: 'Replacement snapshot', - createdAt: '2026-03-20T00:00:00.000Z' - } - - const updatedStorage = reconcileTenderlySnapshotStorageAfterRevert( - { - '1:694201': [baselineSnapshot, revertedSnapshot, descendantSnapshot] - }, - { - revertedSnapshotRecord: revertedSnapshot, - replacementSnapshotRecord: replacementSnapshot - } - ) - - expect( - updatedStorage['1:694201']?.find((record) => record.snapshotId === revertedSnapshot.snapshotId)?.lastKnownStatus - ).toBe('invalid') - expect( - updatedStorage['1:694201']?.find((record) => record.snapshotId === descendantSnapshot.snapshotId)?.lastKnownStatus - ).toBe('invalid') - expect( - updatedStorage['1:694201']?.find((record) => record.snapshotId === replacementSnapshot.snapshotId) - ).toMatchObject({ - snapshotId: '0xsnapshot-replacement', - lastKnownStatus: 'valid', - kind: 'snapshot' - }) - expect(getLastRestorableTenderlySnapshot(updatedStorage['1:694201'] || [])?.snapshotId).toBe( - '0xsnapshot-replacement' - ) - }) -}) - -describe('convertTenderlyTimeAmountToSeconds', () => { - it('converts curated units into seconds', () => { - expect(convertTenderlyTimeAmountToSeconds(5, 'minutes')).toBe(300) - expect(convertTenderlyTimeAmountToSeconds(2, 'hours')).toBe(7_200) - expect(convertTenderlyTimeAmountToSeconds(14, 'days')).toBe(1_209_600) - }) -}) - -describe('addTenderlyTimeIncrement', () => { - it('adds preset time to the current input without executing immediately', () => { - expect( - addTenderlyTimeIncrement({ - currentAmount: 14, - currentUnit: 'days', - addedAmount: 1, - addedUnit: 'hours' - }) - ).toEqual({ - amount: 14.041667, - unit: 'days', - seconds: 1_213_200 - }) - }) - - it('uses the preset unit when the current input is empty', () => { - expect( - addTenderlyTimeIncrement({ - currentAmount: 0, - currentUnit: 'days', - addedAmount: 1, - addedUnit: 'days' - }) - ).toEqual({ - amount: 1, - unit: 'days', - seconds: 86_400 - }) - }) -}) - -describe('buildTenderlyFundableAssets', () => { - it('includes native, token-list, vault, and staking assets for the selected chain', () => { - const tokenLists = { - 1: { - '0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9': { - address: '0x7Fc66500c84A76Ad7E9C93437bFc5Ac33E2DDAE9', - name: 'Aave Token', - symbol: 'AAVE', - decimals: 18, - chainID: 1, - value: 0, - balance: { raw: 0n, normalized: 0, display: '0', decimals: 18 } - } satisfies TToken, - '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - chainID: 1, - value: 0, - balance: { raw: 0n, normalized: 0, display: '0', decimals: 6 } - } satisfies TToken - } - } - const allVaults = { - '0xvault': { - address: '0x1111111111111111111111111111111111111111', - chainId: 1, - name: 'Test Vault', - symbol: 'yvTEST', - decimals: 18, - token: { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - name: 'USD Coin', - symbol: 'USDC', - decimals: 6 - }, - staking: { - address: '0x2222222222222222222222222222222222222222', - available: true, - source: '', - rewards: [] - } - } - } as unknown as TDict - - const assets = buildTenderlyFundableAssets({ - chainId: 1, - tokenLists, - allVaults - }) - - expect(assets.some((asset) => asset.assetKind === 'native' && asset.symbol === 'ETH')).toBe(true) - expect(assets.some((asset) => asset.symbol === 'USDC' && asset.tokenType === 'asset')).toBe(true) - expect(assets.some((asset) => asset.symbol === 'yvTEST' && asset.tokenType === 'vault')).toBe(true) - expect(assets.some((asset) => asset.symbol === 'yvTEST' && asset.tokenType === 'staking')).toBe(true) - expect(assets.findIndex((asset) => asset.symbol === 'USDC')).toBeLessThan( - assets.findIndex((asset) => asset.symbol === 'AAVE') - ) - }) - - it('builds a deduplicated default asset list for the faucet without repeated common symbols', () => { - const defaultAssets = getDefaultTenderlyFundableAssets( - [ - { - chainId: 1, - address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', - name: 'Ether', - symbol: 'ETH', - decimals: 18, - assetKind: 'native', - tokenType: 'asset' - }, - { - chainId: 1, - address: '0x13Cc8D626445c6fcCC548aAE172CBACF572EF5A4', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - assetKind: 'erc20', - tokenType: 'asset' - }, - { - chainId: 1, - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - assetKind: 'erc20', - tokenType: 'asset' - }, - { - chainId: 1, - address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', - name: 'Dai Stablecoin', - symbol: 'DAI', - decimals: 18, - assetKind: 'erc20', - tokenType: 'asset' - } - ], - 14 - ) - - expect(defaultAssets.map((asset) => asset.symbol)).toEqual(['ETH', 'USDT', 'DAI']) - expect(defaultAssets[1]?.address).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7') - }) -}) diff --git a/src/components/shared/utils/wagmi/utils.test.ts b/src/components/shared/utils/wagmi/utils.test.ts deleted file mode 100644 index 9f94e7a08..000000000 --- a/src/components/shared/utils/wagmi/utils.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { mainnet } from 'viem/chains' -import { afterEach, describe, expect, it, vi } from 'vitest' - -describe('getNetwork', () => { - afterEach(() => { - vi.resetModules() - vi.doUnmock('@/config/tenderly') - }) - - it('leaves the default block explorer empty for Tenderly execution chains without explicit explorer URIs', async () => { - vi.doMock('@/config/tenderly', () => ({ - resolveExecutionChainId: (chainId?: number) => chainId, - resolveTenderlyExplorerUriForExecutionChainId: () => undefined, - resolveTenderlyRpcUriForExecutionChainId: (chainId?: number) => - chainId === 73571 ? 'https://rpc.tenderly.ethereum.example' : undefined, - supportedChainLookup: [{ ...mainnet, id: 73571, name: 'Ethereum Tenderly', blockExplorers: undefined }] - })) - - const { getNetwork } = await import('./utils') - - expect(getNetwork(73571).defaultBlockExplorer).toBe('') - }) -}) diff --git a/src/config/supportedChains.test.ts b/src/config/supportedChains.test.ts deleted file mode 100644 index 8f9477888..000000000 --- a/src/config/supportedChains.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Chain } from 'viem' -import { afterEach, describe, expect, it, vi } from 'vitest' - -describe('supportedChains exports', () => { - afterEach(() => { - vi.resetModules() - vi.doUnmock('./tenderly') - }) - - it('keeps app chains canonical while wallet chains can use execution IDs', async () => { - vi.doMock('./tenderly', () => ({ - supportedCanonicalChains: [{ id: 1, name: 'Ethereum' } satisfies Partial as Chain], - supportedExecutionChains: [{ id: 73571, name: 'Ethereum Tenderly' } satisfies Partial as Chain] - })) - - const module = await import('./supportedChains') - - expect(module.supportedChains.map((chain) => chain.id)).toEqual([1]) - expect(module.supportedAppChains.map((chain) => chain.id)).toEqual([1]) - expect(module.supportedWalletChains.map((chain) => chain.id)).toEqual([73571]) - }) -}) diff --git a/src/config/tenderly.test.ts b/src/config/tenderly.test.ts deleted file mode 100644 index a80c1d14d..000000000 --- a/src/config/tenderly.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { canonicalChains } from './chainDefinitions' -import { - getSupportedCanonicalChainsForRuntime, - getSupportedChainLookupForRuntime, - getSupportedExecutionChainsForRuntime, - isConnectedToExecutionChainForRuntime, - parseTenderlyRuntime, - resolveCanonicalChainIdForRuntime, - resolveConnectedCanonicalChainIdForRuntime, - resolveExecutionChainIdForRuntime, - resolveTenderlyExplorerUriForExecutionChainIdForRuntime, - resolveTenderlyRpcUriForExecutionChainIdForRuntime -} from './tenderly' - -describe('parseTenderlyRuntime', () => { - it('returns the canonical chain set when Tenderly mode is disabled', () => { - const runtime = parseTenderlyRuntime({}) - const canonicalChainIds = canonicalChains.map((chain) => chain.id) - - expect(runtime.isEnabled).toBe(false) - expect(getSupportedCanonicalChainsForRuntime(runtime).map((chain) => chain.id)).toEqual(canonicalChainIds) - expect(resolveExecutionChainIdForRuntime(runtime, 1)).toBe(1) - expect(resolveExecutionChainIdForRuntime(runtime, 1337)).toBe(1337) - expect(resolveCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) - expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) - expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1)).toBe(1) - expect(isConnectedToExecutionChainForRuntime(runtime, 1, 1)).toBe(true) - }) - - it('requires both chain id and rpc uri when a Tenderly chain is configured', () => { - expect(() => - parseTenderlyRuntime({ - VITE_TENDERLY_MODE: 'true', - VITE_TENDERLY_CHAIN_ID_FOR_1: '73571' - }) - ).toThrow(/requires both VITE_TENDERLY_CHAIN_ID_FOR_1 and VITE_TENDERLY_RPC_URI_FOR_1/) - }) - - it('rejects duplicate Tenderly execution chain ids', () => { - expect(() => - parseTenderlyRuntime({ - VITE_TENDERLY_MODE: 'true', - VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', - VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example', - VITE_TENDERLY_CHAIN_ID_FOR_10: '73571', - VITE_TENDERLY_RPC_URI_FOR_10: 'https://rpc.tenderly.optimism.example' - }) - ).toThrow(/Duplicate Tenderly execution chain ID 73571 configured for canonical chains 1 and 10/) - }) - - it('filters canonical chains and resolves execution chain ids from runtime config', () => { - const runtime = parseTenderlyRuntime({ - VITE_TENDERLY_MODE: 'true', - VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', - VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example', - VITE_TENDERLY_EXPLORER_URI_FOR_1: 'https://explorer.tenderly.ethereum.example', - VITE_TENDERLY_CHAIN_ID_FOR_10: '73572', - VITE_TENDERLY_RPC_URI_FOR_10: 'https://rpc.tenderly.optimism.example' - }) - - const supportedCanonicalChains = getSupportedCanonicalChainsForRuntime(runtime) - const supportedExecutionChains = getSupportedExecutionChainsForRuntime(runtime, supportedCanonicalChains) - const supportedLookupChains = getSupportedChainLookupForRuntime( - runtime, - supportedCanonicalChains, - supportedExecutionChains - ) - - expect(runtime.configuredCanonicalChainIds).toEqual([1, 10]) - expect(supportedCanonicalChains.map((chain) => chain.id)).toEqual([1, 10]) - expect(supportedCanonicalChains[0].rpcUrls.default.http[0]).toBe('https://rpc.tenderly.ethereum.example') - expect(supportedCanonicalChains[0].blockExplorers?.default.url).toBe('https://explorer.tenderly.ethereum.example') - - expect(supportedExecutionChains.map((chain) => chain.id)).toEqual([73571, 73572]) - expect(supportedExecutionChains[0].name).toContain('Tenderly') - expect(supportedExecutionChains[0].testnet).toBe(true) - - expect(supportedLookupChains.map((chain) => chain.id)).toEqual([1, 10, 73571, 73572]) - - expect(resolveExecutionChainIdForRuntime(runtime, 1)).toBe(73571) - expect(resolveExecutionChainIdForRuntime(runtime, 73571)).toBe(73571) - expect(resolveExecutionChainIdForRuntime(runtime, 1337)).toBe(1337) - expect(resolveExecutionChainIdForRuntime(runtime, 8453)).toBeUndefined() - - expect(resolveCanonicalChainIdForRuntime(runtime, 1)).toBe(1) - expect(resolveCanonicalChainIdForRuntime(runtime, 73571)).toBe(1) - expect(resolveCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) - expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 73571)).toBe(1) - expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1337)).toBe(1) - expect(resolveConnectedCanonicalChainIdForRuntime(runtime, 1)).toBe(1) - expect(resolveTenderlyRpcUriForExecutionChainIdForRuntime(runtime, 73571)).toBe( - 'https://rpc.tenderly.ethereum.example' - ) - expect(resolveTenderlyExplorerUriForExecutionChainIdForRuntime(runtime, 73571)).toBe( - 'https://explorer.tenderly.ethereum.example' - ) - expect(resolveTenderlyRpcUriForExecutionChainIdForRuntime(runtime, 1)).toBeUndefined() - expect(isConnectedToExecutionChainForRuntime(runtime, 1, 1)).toBe(false) - expect(isConnectedToExecutionChainForRuntime(runtime, 73571, 1)).toBe(true) - expect(isConnectedToExecutionChainForRuntime(runtime, 1, 73571)).toBe(false) - expect(isConnectedToExecutionChainForRuntime(runtime, 73571, 73571)).toBe(true) - }) - - it('does not reuse canonical explorers for execution chains without explicit Tenderly explorer URIs', () => { - const runtime = parseTenderlyRuntime({ - VITE_TENDERLY_MODE: 'true', - VITE_TENDERLY_CHAIN_ID_FOR_1: '73571', - VITE_TENDERLY_RPC_URI_FOR_1: 'https://rpc.tenderly.ethereum.example' - }) - const supportedCanonicalChains = getSupportedCanonicalChainsForRuntime(runtime) - const supportedExecutionChains = getSupportedExecutionChainsForRuntime(runtime, supportedCanonicalChains) - - expect(supportedCanonicalChains[0].blockExplorers?.default.url).toBeTruthy() - expect(supportedExecutionChains[0].id).toBe(73571) - expect(supportedExecutionChains[0].blockExplorers).toBeUndefined() - expect(resolveTenderlyExplorerUriForExecutionChainIdForRuntime(runtime, 73571)).toBeUndefined() - }) -}) diff --git a/src/config/wagmiChains.test.ts b/src/config/wagmiChains.test.ts deleted file mode 100644 index 497cd95e1..000000000 --- a/src/config/wagmiChains.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Chain } from 'viem' -import { base, mainnet } from 'viem/chains' -import { describe, expect, it } from 'vitest' -import { getWagmiConfigChains } from './wagmiChains' - -describe('getWagmiConfigChains', () => { - it('keeps the real canonical mainnet when ethereum is part of the supported canonical set', () => { - const tenderlyMainnet = { ...mainnet, id: 73571, name: 'Tenderly Mainnet' } as Chain - const canonicalMainnet = { - ...mainnet, - rpcUrls: { ...mainnet.rpcUrls, default: { http: ['https://rpc.tenderly.ethereum.example'] } } - } as Chain - - expect(getWagmiConfigChains([tenderlyMainnet], [canonicalMainnet]).map((chain) => chain.id)).toEqual([73571, 1]) - expect(getWagmiConfigChains([tenderlyMainnet], [canonicalMainnet])[1].rpcUrls.default.http[0]).toBe( - mainnet.rpcUrls.default.http[0] - ) - }) - - it('does not duplicate canonical mainnet when it is already configured', () => { - const tenderlyMainnet = { ...mainnet, id: 73571, name: 'Tenderly Mainnet' } as Chain - - expect(getWagmiConfigChains([mainnet, tenderlyMainnet]).map((chain) => chain.id)).toEqual([1, 73571]) - }) - - it('keeps canonical non-mainnet chains alongside Tenderly execution chains', () => { - const tenderlyBase = { ...base, id: 84531, name: 'Base Tenderly' } as Chain - - expect(getWagmiConfigChains([tenderlyBase], [base]).map((chain) => chain.id)).toEqual([84531, 8453]) - }) - - it('drops Tenderly-overridden canonical mainnet in favor of the real mainnet entry', () => { - const tenderlyMainnet = { - ...mainnet, - rpcUrls: { ...mainnet.rpcUrls, default: { http: ['https://rpc.tenderly.example'] } } - } as Chain - - expect(getWagmiConfigChains([], [tenderlyMainnet]).map((chain) => chain.id)).toEqual([1]) - expect(getWagmiConfigChains([], [tenderlyMainnet])[0].rpcUrls.default.http[0]).toBe(mainnet.rpcUrls.default.http[0]) - }) - - it('does not advertise canonical mainnet when ethereum is not part of the supported canonical set', () => { - const tenderlyBase = { ...base, id: 84531, name: 'Base Tenderly' } as Chain - - expect(getWagmiConfigChains([tenderlyBase], [base]).some((chain) => chain.id === mainnet.id)).toBe(false) - }) -}) diff --git a/src/config/wagmiTransports.test.ts b/src/config/wagmiTransports.test.ts deleted file mode 100644 index 8d46cde59..000000000 --- a/src/config/wagmiTransports.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { base, mainnet } from 'viem/chains' -import { afterEach, describe, expect, it, vi } from 'vitest' - -describe('getTransportRpcUrlsForChain', () => { - afterEach(() => { - vi.resetModules() - vi.doUnmock('@shared/utils/wagmi') - }) - - it('preserves the real mainnet transport for the canonical wagmi mainnet chain', async () => { - vi.doMock('@shared/utils/wagmi', () => ({ - getNetwork: (chainId: number) => ({ - defaultRPC: chainId === 1 ? 'https://rpc.tenderly.ethereum.example' : '' - }), - getRpcUriFor: () => '', - registerConfig: () => undefined - })) - - const { getTransportRpcUrlsForChain } = await import('./wagmiTransports') - - expect(getTransportRpcUrlsForChain(mainnet)).toEqual([mainnet.rpcUrls.default.http[0]]) - }) - - it('keeps the indexed Tenderly transport for non-mainnet chains', async () => { - vi.doMock('@shared/utils/wagmi', () => ({ - getNetwork: (chainId: number) => ({ - defaultRPC: chainId === base.id ? 'https://rpc.tenderly.base.example' : '' - }), - getRpcUriFor: () => '', - registerConfig: () => undefined - })) - - const { getTransportRpcUrlsForChain } = await import('./wagmiTransports') - - expect(getTransportRpcUrlsForChain(base)[0]).toBe('https://rpc.tenderly.base.example') - }) -}) From a2760700d4f1e855e990197758398c7fb080dc93 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:58:24 +0100 Subject: [PATCH 12/15] feat: bring back essential tests --- CLAUDE.md | 9 +- .../yvUSD/YvUsdWithdraw.helpers.test.ts | 522 ------------------ .../widget/yvUSD/cooldownUtils.test.ts | 56 -- .../vaults/domain/kongVaultSelectors.test.ts | 343 ------------ .../hooks/useYvUsdVaults.helpers.test.ts | 238 -------- src/test/YvUsdWithdraw.helpers.test.ts | 211 +++++++ src/test/cooldownUtils.test.ts | 22 + .../shared/utils => test}/format.test.ts | 2 +- src/test/kongVaultSelectors.test.ts | 45 ++ src/test/stakingWithdrawable.test.ts | 85 +++ .../strategiesPercentFormat.test.ts | 3 +- .../hooks => test}/useVaultApyData.test.ts | 2 +- .../useVaultFilterUtils.test.ts | 14 +- .../useYvUsdCharts.helpers.test.ts | 2 +- src/test/useYvUsdVaults.helpers.test.ts | 20 + .../widget/deposit => test}/valuation.test.ts | 5 +- .../shared/utils => test}/vaultApy.test.ts | 4 +- .../pages/vaults/utils => test}/yvUsd.test.ts | 35 +- 18 files changed, 403 insertions(+), 1215 deletions(-) delete mode 100644 src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts delete mode 100644 src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts delete mode 100644 src/components/pages/vaults/domain/kongVaultSelectors.test.ts delete mode 100644 src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts create mode 100644 src/test/YvUsdWithdraw.helpers.test.ts create mode 100644 src/test/cooldownUtils.test.ts rename src/{components/shared/utils => test}/format.test.ts (96%) create mode 100644 src/test/kongVaultSelectors.test.ts create mode 100644 src/test/stakingWithdrawable.test.ts rename src/{components/pages/vaults/components/detail => test}/strategiesPercentFormat.test.ts (88%) rename src/{components/pages/vaults/hooks => test}/useVaultApyData.test.ts (96%) rename src/{components/shared/hooks => test}/useVaultFilterUtils.test.ts (80%) rename src/{components/pages/vaults/hooks => test}/useYvUsdCharts.helpers.test.ts (97%) create mode 100644 src/test/useYvUsdVaults.helpers.test.ts rename src/{components/pages/vaults/components/widget/deposit => test}/valuation.test.ts (96%) rename src/{components/shared/utils => test}/vaultApy.test.ts (99%) rename src/{components/pages/vaults/utils => test}/yvUsd.test.ts (85%) diff --git a/CLAUDE.md b/CLAUDE.md index b78d201b1..262d6e56d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,9 +25,12 @@ Husky runs `lint-staged` + `bun run tslint` on every commit. ## Testing -- NEVER create UI/component tests (tests that use `render`, `screen`, `@testing-library/react`, or similar) -- Only create tests for math/calculation logic (APY/APR, price impact, share/asset conversions, formatting numbers, duration math) -- Do NOT create tests for: config assertions, boolean flag logic, string/URL mapping, route resolution, schema validation, CSS classes, array/set manipulation, state machines, or ABI construction +All tests live in `src/test/` — focused on math and calculations where a wrong decimal silently loses user funds. Expected values should be human-verified independently of the code. Do not test boolean flag logic, string mapping, config assertions, or control flow — an AI will just adjust expected values to match the implementation, making those tests circular. + +**Deprioritised:** UI/component tests (`render`, `screen`, `@testing-library/react`). Low value because when AI writes both the implementation and the tests, it validates its own assumptions against itself — when the implementation changes, the tests get rewritten to match, so they never actually catch anything. + +- Never mirror the source tree 1:1. Group related calculations into a single test file per domain. +- Never create a new test file for a single helper — find the test file it belongs in, or create one if a new domain is emerging ## Code Style diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts deleted file mode 100644 index 790adf034..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildLockedWithdrawNoZapExecutionPlan, - buildLockedWithdrawTransactionStep, - getLockedCooldownMaxAssetAmount, - getLockedCooldownMaxDisplayAmount, - resolveCooldownSharesToStart, - resolveLockedRedeemAssets, - resolveLockedRequestedAmountFromInput, - resolveLockedRequestedWithdrawAssets, - resolveLockedRequestedWithdrawShares, - resolveLockedWithdrawDisplayAmount, - resolveLockedWithdrawExecutionSnapshot, - resolveLockedWithdrawExpectedOut, - resolveLockedWithdrawMethod, - shouldNormalizeLockedWithdrawDisplayAmount, - shouldUseLockedManagedWithdrawFlow -} from './YvUsdWithdraw.helpers' - -const TOKEN_A = '0x0000000000000000000000000000000000000001' as const -const TOKEN_B = '0x0000000000000000000000000000000000000002' as const - -const mockPrepare = (functionName: string, args: readonly unknown[]) => - ({ - isSuccess: true, - data: { - request: { - functionName, - args - } - } - }) as any - -describe('resolveLockedWithdrawDisplayAmount', () => { - it('uses previewRedeem for the displayed max when it is available', () => { - expect( - resolveLockedWithdrawDisplayAmount({ - maxWithdrawAssets: 50_000000000000000000n, - previewRedeemAssets: 51_000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) - - it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { - expect( - resolveLockedWithdrawDisplayAmount({ - maxWithdrawAssets: 50_000000000000000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) -}) - -describe('resolveLockedRequestedWithdrawAssets', () => { - it('snaps an exact max input back to the exact locked asset cap', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 51_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 49_999999999999999999n - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw for partial requests and clamps them to the cap', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 25_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 60_000000000000000000n - }) - ).toBe(50_000000000000000000n) - }) - - it('keeps over-limit withdraw inputs above the cap so the widget can show an error instead of snapping to max', () => { - expect( - resolveLockedRequestedWithdrawAssets({ - requestedDisplayAmount: 52_000000n, - maxDisplayAmount: 51_000000n, - maxWithdrawAssets: 50_000000000000000000n, - previewWithdrawShares: 51_500000000000000000n - }) - ).toBe(51_500000000000000000n) - }) -}) - -describe('resolveLockedRequestedWithdrawShares', () => { - it('snaps an exact max asset request back to the exact redeemable share cap', () => { - expect( - resolveLockedRequestedWithdrawShares({ - requestedLockedAssets: 50_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - previewWithdrawShares: 49_900000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(49_999999999999999999n) - }) - - it('uses previewWithdraw shares for partial requests and clamps them to maxRedeem', () => { - expect( - resolveLockedRequestedWithdrawShares({ - requestedLockedAssets: 25_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_000000000000000000n, - previewWithdrawShares: 60_000000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(49_000000000000000000n) - }) -}) - -describe('cooldown max selection', () => { - it('derives the exact cooldown asset cap from the locked share balance', () => { - expect( - getLockedCooldownMaxAssetAmount({ - lockedWalletShares: 50_000000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) - - it('derives the displayed cooldown max amount from the exact cooldown asset cap', () => { - expect( - getLockedCooldownMaxDisplayAmount({ - maxCooldownAssetAmount: 50_000000000000000000n, - isLockedUnderlyingDisplay: true, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(51_000000n) - }) - - it('snaps a pre-cooldown max input back to the exact cooldown asset cap', () => { - expect( - resolveLockedRequestedAmountFromInput({ - amount: 51_000000n, - inputUnit: 'underlying', - canWithdrawNow: false, - needsCooldownStart: true, - maxCooldownDisplayAmount: 51_000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawLockedAssets: 49_999999999999999999n, - lockedDisplayPricePerShare: 1_020000n, - lockedVaultTokenDecimals: 18, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw for partial pre-cooldown underlying input when available', () => { - expect( - resolveLockedRequestedAmountFromInput({ - amount: 25_000000n, - inputUnit: 'underlying', - canWithdrawNow: false, - needsCooldownStart: true, - maxCooldownDisplayAmount: 51_000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawLockedAssets: 24_600000000000000000n, - lockedDisplayPricePerShare: 1_020000n, - lockedVaultTokenDecimals: 18, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(24_600000000000000000n) - }) - - it('starts cooldown with the full share balance when the exact cooldown asset cap is selected', () => { - expect( - resolveCooldownSharesToStart({ - needsCooldownStart: true, - lockedRequestedAmountRaw: 50_000000000000000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawShares: 49_999999999999999999n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18, - lockedWalletShares: 50_000000000000000000n - }) - ).toBe(50_000000000000000000n) - }) - - it('uses previewWithdraw shares for partial cooldown selection when available', () => { - expect( - resolveCooldownSharesToStart({ - needsCooldownStart: true, - lockedRequestedAmountRaw: 24_600000000000000000n, - maxCooldownAssetAmount: 50_000000000000000000n, - previewWithdrawShares: 23_700000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18, - lockedWalletShares: 50_000000000000000000n - }) - ).toBe(23_700000000000000000n) - }) -}) - -describe('resolveLockedRedeemAssets', () => { - it('uses previewRedeem for partial locked-leg share redemptions when it is available', () => { - expect( - resolveLockedRedeemAssets({ - requestedLockedShares: 25_000000000000000000n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - previewRedeemAssets: 24_800000000000000000n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(24_800000000000000000n) - }) - - it('uses the authoritative maxWithdraw assets for an exact max redeem', () => { - expect( - resolveLockedRedeemAssets({ - requestedLockedShares: 49_999999999999999999n, - maxWithdrawAssets: 50_000000000000000000n, - maxRedeemShares: 49_999999999999999999n, - lockedPricePerShare: 1_000000000000000000n, - lockedVaultTokenDecimals: 18 - }) - ).toBe(50_000000000000000000n) - }) -}) - -describe('resolveLockedWithdrawMethod', () => { - it('uses redeem when the redeem quote can satisfy the requested locked assets', () => { - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - redeemableLockedAssets: 50_000000000000000000n - }) - ).toBe('redeem') - }) - - it('falls back to withdraw when redeemable shares or assets cannot satisfy the requested locked assets', () => { - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 1n, - requestedLockedShares: 0n, - redeemableLockedAssets: 0n - }) - ).toBe('withdraw') - - expect( - resolveLockedWithdrawMethod({ - requestedLockedAssets: 100n, - requestedLockedShares: 9n, - redeemableLockedAssets: 99n - }) - ).toBe('withdraw') - }) -}) - -describe('resolveLockedWithdrawExecutionSnapshot', () => { - it('preserves the snapshotted locked-leg output when live quotes collapse after step 1', () => { - expect( - resolveLockedWithdrawExecutionSnapshot({ - executionSnapshot: { - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n - }, - currentLockedWithdrawMethod: 'withdraw', - currentRequestedLockedAssets: 0n, - currentRequestedLockedShares: 0n, - currentReceivedLockedAssets: 0n - }) - ).toEqual({ - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n - }) - }) - - it('uses the current live quote before a step snapshot exists', () => { - expect( - resolveLockedWithdrawExecutionSnapshot({ - executionSnapshot: null, - currentLockedWithdrawMethod: 'withdraw', - currentRequestedLockedAssets: 25_000000000000000000n, - currentRequestedLockedShares: 24_500000000000000000n, - currentReceivedLockedAssets: 25_000000000000000000n - }) - ).toEqual({ - lockedStepMethod: 'withdraw', - requestedLockedAssets: 25_000000000000000000n, - requestedLockedShares: 24_500000000000000000n, - receivedLockedAssets: 25_000000000000000000n - }) - }) -}) - -describe('shouldUseLockedManagedWithdrawFlow', () => { - it('keeps the managed locked flow for the default same-chain underlying exit', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(true) - }) - - it('lets the generic widget flow handle alternate token or cross-chain exits once locked funds are withdrawable', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - selectedTokenAddress: TOKEN_B, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(false) - - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: true, - selectedTokenAddress: TOKEN_A, - selectedChainId: 10, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(false) - }) - - it('continues to use the managed flow while the locked position is not yet withdrawable', () => { - expect( - shouldUseLockedManagedWithdrawFlow({ - canWithdrawNow: false, - selectedTokenAddress: TOKEN_B, - selectedChainId: 10, - chainId: 1, - underlyingAssetAddress: TOKEN_A - }) - ).toBe(true) - }) -}) - -describe('resolveLockedWithdrawExpectedOut', () => { - it('uses previewRedeem for the exact expected output when it is available', () => { - expect( - resolveLockedWithdrawExpectedOut({ - requestedLockedAssets: 25_000000000000000000n, - previewRedeemAssets: 25_500000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(25_500000n) - }) - - it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { - expect( - resolveLockedWithdrawExpectedOut({ - requestedLockedAssets: 25_000000000000000000n, - unlockedPricePerShare: 1_020000n, - unlockedVaultDecimals: 18 - }) - ).toBe(25_500000n) - }) -}) - -describe('shouldNormalizeLockedWithdrawDisplayAmount', () => { - it('normalizes a post-cooldown max selection back to the authoritative display max', () => { - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 500_000001n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 497_396866n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(true) - }) - - it('does not normalize partial withdraws or inactive withdraw windows', () => { - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 250_000000n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 248_698433n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: false, - currentDisplayAmount: 500_000001n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 497_396866n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - - expect( - shouldNormalizeLockedWithdrawDisplayAmount({ - canWithdrawNow: true, - currentDisplayAmount: 520_000000n, - maxDisplayAmount: 500_000000n, - requestedLockedAssets: 520_000001n, - maxWithdrawAssets: 497_396866n - }) - ).toBe(false) - }) -}) - -describe('buildLockedWithdrawNoZapExecutionPlan', () => { - it('builds a two-step no-zap execution plan with a locked redeem followed by an unlocked withdraw', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'redeem', - requestedLockedAssets: 50_000000000000000000n, - requestedLockedShares: 49_999999999999999999n, - requestedUnderlyingAssets: 51_000000n - }) - - expect(plan).toHaveLength(2) - expect(plan[0]?.functionName).toBe('redeem') - expect(plan[1]?.functionName).toBe('withdraw') - expect(plan[0]?.args[0]).toBe(49_999999999999999999n) - expect(plan[1]?.args[0]).toBe(51_000000n) - }) - - it('does not introduce an approval step for the locked no-zap flow', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'redeem', - requestedLockedAssets: 1n, - requestedLockedShares: 1n, - requestedUnderlyingAssets: 1n - }) - - expect(plan.map((step) => step.functionName)).toEqual(['redeem', 'withdraw']) - }) - - it('falls back to a locked withdraw when the redeem path cannot represent the requested assets', () => { - const plan = buildLockedWithdrawNoZapExecutionPlan({ - account: '0x1111111111111111111111111111111111111111', - lockedStepMethod: 'withdraw', - requestedLockedAssets: 1n, - requestedLockedShares: 0n, - requestedUnderlyingAssets: 1n - }) - - expect(plan.map((step) => step.functionName)).toEqual(['withdraw', 'withdraw']) - expect(plan[0]?.args[0]).toBe(1n) - }) -}) - -describe('buildLockedWithdrawTransactionStep', () => { - it('builds wrapper-owned redeem and withdraw overlay steps', () => { - const withdrawStep = buildLockedWithdrawTransactionStep({ - phase: 'withdraw', - lockedStepMethod: 'redeem', - prepareLockedWithdraw: mockPrepare('redeem', [49n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [51n, '0x1', '0x1']), - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n, - expectedUnderlyingOut: 51_000000n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - const withdrawUnderlyingStep = buildLockedWithdrawTransactionStep({ - phase: 'redeem', - lockedStepMethod: 'redeem', - prepareLockedWithdraw: mockPrepare('redeem', [49n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [51n, '0x1', '0x1']), - requestedLockedShares: 49_999999999999999999n, - receivedLockedAssets: 50_000000000000000000n, - expectedUnderlyingOut: 51_000000n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - - expect(withdrawStep.label).toBe('Withdraw to yvUSD') - expect(withdrawUnderlyingStep.label).toBe('Withdraw to USDC') - expect(withdrawStep.completesFlow).toBe(false) - expect(withdrawUnderlyingStep.completesFlow).toBe(true) - expect(withdrawStep.confirmMessage).toContain('Redeeming') - }) - - it('shows withdraw copy when the locked leg falls back to asset-based withdraw', () => { - const withdrawStep = buildLockedWithdrawTransactionStep({ - phase: 'withdraw', - lockedStepMethod: 'withdraw', - prepareLockedWithdraw: mockPrepare('withdraw', [1n, '0x1', '0x1']), - prepareUnlockedWithdraw: mockPrepare('withdraw', [1n, '0x1', '0x1']), - requestedLockedShares: 0n, - receivedLockedAssets: 1n, - expectedUnderlyingOut: 1n, - lockedVaultTokenDecimals: 18, - lockedAssetDecimals: 18, - underlyingDecimals: 6, - lockedVaultTokenSymbol: 'yvUSD (Locked)', - lockedAssetSymbol: 'yvUSD', - underlyingSymbol: 'USDC' - }) - - expect(withdrawStep.confirmMessage).toContain('Withdrawing') - expect(withdrawStep.successTitle).toBe('Locked withdraw successful') - }) -}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts deleted file mode 100644 index 95eb44f19..000000000 --- a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatDays, resolveCooldownWindowState, resolveDurationSeconds } from './cooldownUtils' - -describe('resolveDurationSeconds', () => { - it('uses the contract-provided duration when available', () => { - expect(resolveDurationSeconds(7n * 86_400n, 5)).toBe(604800) - }) - - it('falls back to the configured number of days when contract data is unavailable', () => { - expect(resolveDurationSeconds(undefined, 5)).toBe(432000) - }) -}) - -describe('formatDays', () => { - it('formats the 5-day withdrawal fallback label correctly', () => { - expect(formatDays(resolveDurationSeconds(undefined, 5), 5)).toBe('5 days') - }) - - it('formats contract-provided withdrawal windows without forcing the fallback value', () => { - expect(formatDays(resolveDurationSeconds(7n * 86_400n, 5), 5)).toBe('7 days') - }) -}) - -describe('resolveCooldownWindowState', () => { - it('treats a positive withdraw limit as an open window even if local countdown math would lag', () => { - expect( - resolveCooldownWindowState({ - hasActiveCooldown: true, - nowTimestamp: 100, - cooldownEnd: 200, - windowEnd: 300, - availableWithdrawLimit: 1n - }) - ).toEqual({ - isCooldownActive: false, - isWithdrawalWindowOpen: true, - isCooldownWindowExpired: false - }) - }) - - it('marks the cooldown expired only when the withdrawal window has passed and no withdraw limit exists', () => { - expect( - resolveCooldownWindowState({ - hasActiveCooldown: true, - nowTimestamp: 301, - cooldownEnd: 200, - windowEnd: 300, - availableWithdrawLimit: 0n - }) - ).toEqual({ - isCooldownActive: false, - isWithdrawalWindowOpen: false, - isCooldownWindowExpired: true - }) - }) -}) diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts deleted file mode 100644 index 9e299a524..000000000 --- a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getVaultAPR, getVaultStaking, getVaultStrategies } from './kongVaultSelectors' - -const LIST_REWARD = { - address: '0x3333333333333333333333333333333333333333', - name: 'List Reward', - symbol: 'LR', - decimals: 18, - price: 1, - isFinished: false, - finishedAt: 0, - apr: 0.5, - perWeek: 10 -} - -const SNAPSHOT_REWARD = { - address: '0x4444444444444444444444444444444444444444', - name: 'Snapshot Reward', - symbol: 'SR', - decimals: 6, - price: 2, - isFinished: true, - finishedAt: 123, - apr: 1.5, - perWeek: 20 -} - -describe('getVaultStaking', () => { - it('preserves list staking source and rewards when snapshot metadata is missing', () => { - const vault = { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: false, - source: 'yBOLD', - rewards: [LIST_REWARD] - } - } as any - - const staking = getVaultStaking(vault, { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: true - } - } as any) - - expect(staking.source).toBe('yBOLD') - expect(staking.rewards ?? []).toHaveLength(1) - expect(staking.rewards?.[0].symbol).toBe('LR') - }) - - it('prefers snapshot staking source and rewards when they are present', () => { - const vault = { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: false, - source: 'legacy', - rewards: [LIST_REWARD] - } - } as any - - const staking = getVaultStaking(vault, { - staking: { - address: '0x2222222222222222222222222222222222222222', - available: true, - source: 'VeYFI', - rewards: [SNAPSHOT_REWARD] - } - } as any) - - expect(staking.source).toBe('VeYFI') - expect(staking.rewards ?? []).toHaveLength(1) - expect(staking.rewards?.[0].symbol).toBe('SR') - }) -}) - -describe('getVaultAPR', () => { - it('uses list pricePerShare when snapshot pricePerShare is missing', () => { - const apr = getVaultAPR({ - chainId: 1, - address: '0x1111111111111111111111111111111111111111', - name: 'Vault', - symbol: 'yvTEST', - decimals: 18, - asset: { - address: '0x2222222222222222222222222222222222222222', - name: 'USDC', - symbol: 'USDC', - decimals: 6 - }, - tvl: 1000, - performance: { - oracle: { apr: 0.02, apy: 0.02 }, - estimated: { apr: 0.02, apy: 0.02, type: 'oracle', components: {} }, - historical: { net: 0.01, weeklyNet: 0.01, monthlyNet: 0.01, inceptionNet: 0.01 } - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - category: 'Stablecoin', - type: 'Standard', - kind: 'Single Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: false, - isBoosted: false, - isHighlighted: false, - strategiesCount: 1, - riskLevel: 1, - staking: null, - pricePerShare: '1050000' - } as any) - - expect(apr.pricePerShare.today).toBeCloseTo(1.05, 8) - }) -}) - -describe('getVaultStrategies', () => { - const vault = { - chainId: 1, - address: '0x1111111111111111111111111111111111111111' - } as any - - it('prefers composition estimated apy over oracle apy', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x5555555555555555555555555555555555555555', - name: 'Strategy A', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.07, - apy: 0.12, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08, - apy: 0.09 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.12) - }) - - it('falls back to composition oracle apy when estimated apy is missing', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x6666666666666666666666666666666666666666', - name: 'Strategy B', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08, - apy: 0.09 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.09) - }) - - it('leaves estimated apy unset when neither estimated nor oracle apy exists', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x7777777777777777777777777777777777777777', - name: 'Strategy C', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - oracle: { - apr: 0.08 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBeUndefined() - }) - - it('uses oracle apy as base for katana strategies — estimated apr is KAT rewards only', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x8888888888888888888888888888888888888888', - name: 'Morpho Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.0028, - type: 'katana-estimated-apr' - }, - oracle: { - apr: 0.03, - apy: 0.04 - } - } - } - ] - } as any) - - // estimatedAPY should be oracle.apy (base yield), not estimated.apr (KAT rewards) - expect(strategies[0]?.estimatedAPY).toBe(0.04) - expect(strategies[0]?.katRewardsAPR).toBe(0.0028) - }) - - it('leaves estimatedAPY undefined for katana strategies when neither estimated.apy nor oracle.apy exists', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x8888888888888888888888888888888888888888', - name: 'Morpho Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.0028, - type: 'katana-estimated-apr' - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBeUndefined() - expect(strategies[0]?.katRewardsAPR).toBe(0.0028) - }) - - it('reads katRewardsAPR from estimated components when apr is omitted', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - name: 'Katana Strategy with Components', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - type: 'katana-estimated-apr', - components: { - katRewardsAPR: 0.002978698024448475 - } - }, - oracle: { - apr: 0.013945609013431531, - apy: 0.014041406702504533 - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.014041406702504533) - expect(strategies[0]?.katRewardsAPR).toBe(0.002978698024448475) - }) - - it('does not set katRewardsAPR for non-katana strategies', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0x9999999999999999999999999999999999999999', - name: 'Regular Strategy', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.05, - apy: 0.06, - type: 'yvusd-estimated-apr', - components: {} - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.06) - expect(strategies[0]?.katRewardsAPR).toBeUndefined() - }) - - it('prefers estimated apy over estimated apr even for katana strategies', () => { - const strategies = getVaultStrategies(vault, { - totalAssets: '1000000', - composition: [ - { - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - name: 'Katana Strategy with APY', - status: 'active', - totalDebt: '500000', - currentDebt: '500000', - performance: { - estimated: { - apr: 0.003, - apy: 0.05, - type: 'katana-estimated-apr', - components: {} - } - } - } - ] - } as any) - - expect(strategies[0]?.estimatedAPY).toBe(0.05) - expect(strategies[0]?.katRewardsAPR).toBe(0.003) - }) -}) diff --git a/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts b/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts deleted file mode 100644 index 87474d39b..000000000 --- a/src/components/pages/vaults/hooks/useYvUsdVaults.helpers.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '../utils/yvUsd' -import { buildSyntheticBaseVault, buildYvUsdVaultsModel, getYvUsdTvlBreakdown } from './useYvUsdVaults.helpers' - -const UNDERLYING_ASSET = { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - chainId: 1 -} as const - -const UNLOCKED_SNAPSHOT = { - address: YVUSD_UNLOCKED_ADDRESS, - apiVersion: '3.0.4', - decimals: 6, - totalAssets: '1000000000', - asset: UNDERLYING_ASSET, - apy: { - net: 0.04, - weeklyNet: 0.05, - monthlyNet: 0.04, - pricePerShare: '1010000', - weeklyPricePerShare: '1008000', - monthlyPricePerShare: '1005000' - }, - tvl: { - close: 1000 - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - meta: { - kind: 'Multi Strategy', - type: 'Automated Yearn Vault', - category: 'Stablecoin', - token: UNDERLYING_ASSET, - isRetired: false, - isHidden: true, - isBoosted: false - }, - composition: [ - { - address: '0x5f9DBa2805411a8382FDb4E69d4f2Da8EFaF1F89', - name: 'Infinifi sIUSD Morpho Looper', - status: 'active', - currentDebt: '250000000', - totalDebt: '250000000', - totalGain: '0', - totalLoss: '0', - performanceFee: 0, - lastReport: 1_773_076_679, - performance: { - oracle: { - apy: 0, - apr: 0 - }, - historical: { - net: 0.02, - weeklyNet: 0.03, - monthlyNet: 0.02, - inceptionNet: 0.01 - } - } - } - ], - debts: [] -} as const - -const LOCKED_SNAPSHOT = { - address: YVUSD_LOCKED_ADDRESS, - apiVersion: '3.0.4', - decimals: 6, - totalAssets: '500000000', - asset: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6, - chainId: 1 - }, - apy: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - pricePerShare: '1030000', - weeklyPricePerShare: '1020000', - monthlyPricePerShare: '1010000' - }, - tvl: { - close: 500 - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - meta: { - kind: 'Multi Strategy', - type: 'Automated Yearn Vault', - category: 'Stablecoin', - token: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6, - chainId: 1 - }, - isRetired: false, - isHidden: true, - isBoosted: false - }, - performance: { - estimated: { - apr: 0.1, - apy: 0.11, - type: 'yvusd-estimated-apr', - components: { - baseNetAPR: 0.08, - lockerBonusAPR: 0.03 - } - }, - historical: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - inceptionNet: 0.4 - } - }, - composition: [], - debts: [] -} as const - -const LOCKED_LIST_BASE_VAULT = { - chainId: 1, - address: YVUSD_LOCKED_ADDRESS, - name: 'Locked yvUSD', - symbol: 'Locked yvUSD', - apiVersion: '3.0.4', - decimals: 6, - asset: { - address: YVUSD_UNLOCKED_ADDRESS, - name: 'USD yVault', - symbol: 'yvUSD', - decimals: 6 - }, - tvl: 500, - performance: { - oracle: { - apr: 0.02, - apy: 0.02 - }, - estimated: { - apr: 0.1, - apy: 0.11, - type: 'yvusd-estimated-apr', - components: {} - }, - historical: { - net: 0.5, - weeklyNet: 0.52, - monthlyNet: 0.5, - inceptionNet: 0.4 - } - }, - fees: { - managementFee: 0, - performanceFee: 0 - }, - category: 'Stablecoin', - type: 'Automated Yearn Vault', - kind: 'Multi Strategy', - v3: true, - yearn: true, - isRetired: false, - isHidden: true, - isBoosted: false, - isHighlighted: false, - inclusion: { - isYearn: true - }, - migration: false, - origin: 'yearn', - strategiesCount: 0, - riskLevel: 1, - staking: null, - pricePerShare: '1030000' -} as const - -describe('buildYvUsdVaultsModel', () => { - it('builds the combined yvUSD model from Kong snapshots and points booleans', () => { - const baseVault = buildSyntheticBaseVault(UNLOCKED_SNAPSHOT as any) - const model = buildYvUsdVaultsModel({ - baseVault, - unlockedSnapshot: UNLOCKED_SNAPSHOT as any, - lockedSnapshot: LOCKED_SNAPSHOT as any, - points: { - unlocked: true, - locked: false - } - }) - - expect(model.assetAddress).toBe(UNDERLYING_ASSET.address) - expect(model.listVault.tvl.tvl).toBe(1000) - expect(model.lockedVault.apr.forwardAPR.netAPR).toBe(0.11) - expect(model.unlockedVault.strategies?.[0].name).toBe('Infinifi sIUSD Morpho Looper') - expect(model.metrics.unlocked.hasInfinifiPoints).toBe(true) - expect(model.metrics.locked.hasInfinifiPoints).toBe(false) - }) - - it('prefers the unlocked snapshot asset over a locked-base vault asset when resolving deposits', () => { - const model = buildYvUsdVaultsModel({ - baseVault: LOCKED_LIST_BASE_VAULT as any, - unlockedSnapshot: UNLOCKED_SNAPSHOT as any, - lockedSnapshot: LOCKED_SNAPSHOT as any - }) - - expect(model.assetAddress).toBe(UNDERLYING_ASSET.address) - }) -}) - -describe('getYvUsdTvlBreakdown', () => { - it('treats the unlocked vault TVL as total and derives the unlocked remainder after locked TVL', () => { - expect(getYvUsdTvlBreakdown({ totalTvl: 1000, lockedTvl: 500 })).toEqual({ - totalTvl: 1000, - unlockedTvl: 500, - lockedTvl: 500 - }) - }) - - it('clamps the unlocked remainder at zero when locked TVL exceeds total TVL', () => { - expect(getYvUsdTvlBreakdown({ totalTvl: 100, lockedTvl: 120 })).toEqual({ - totalTvl: 100, - unlockedTvl: 0, - lockedTvl: 120 - }) - }) -}) diff --git a/src/test/YvUsdWithdraw.helpers.test.ts b/src/test/YvUsdWithdraw.helpers.test.ts new file mode 100644 index 000000000..db1dd175d --- /dev/null +++ b/src/test/YvUsdWithdraw.helpers.test.ts @@ -0,0 +1,211 @@ +import { + getLockedCooldownMaxAssetAmount, + getLockedCooldownMaxDisplayAmount, + resolveCooldownSharesToStart, + resolveLockedRedeemAssets, + resolveLockedRequestedAmountFromInput, + resolveLockedRequestedWithdrawAssets, + resolveLockedRequestedWithdrawShares, + resolveLockedWithdrawDisplayAmount +} from '@pages/vaults/components/widget/yvUSD/YvUsdWithdraw.helpers' +import { describe, expect, it } from 'vitest' + +describe('resolveLockedWithdrawDisplayAmount', () => { + it('uses previewRedeem for the displayed max when it is available', () => { + expect( + resolveLockedWithdrawDisplayAmount({ + maxWithdrawAssets: 50_000000000000000000n, + previewRedeemAssets: 51_000000n, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) + + it('falls back to price-per-share conversion when previewRedeem is unavailable', () => { + expect( + resolveLockedWithdrawDisplayAmount({ + maxWithdrawAssets: 50_000000000000000000n, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) +}) + +describe('resolveLockedRequestedWithdrawAssets', () => { + it('snaps an exact max input back to the exact locked asset cap', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 51_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 49_999999999999999999n + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw for partial requests and clamps them to the cap', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 25_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 60_000000000000000000n + }) + ).toBe(50_000000000000000000n) + }) + + it('keeps over-limit withdraw inputs above the cap so the widget can show an error instead of snapping to max', () => { + expect( + resolveLockedRequestedWithdrawAssets({ + requestedDisplayAmount: 52_000000n, + maxDisplayAmount: 51_000000n, + maxWithdrawAssets: 50_000000000000000000n, + previewWithdrawShares: 51_500000000000000000n + }) + ).toBe(51_500000000000000000n) + }) +}) + +describe('resolveLockedRequestedWithdrawShares', () => { + it('snaps an exact max asset request back to the exact redeemable share cap', () => { + expect( + resolveLockedRequestedWithdrawShares({ + requestedLockedAssets: 50_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + previewWithdrawShares: 49_900000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(49_999999999999999999n) + }) + + it('uses previewWithdraw shares for partial requests and clamps them to maxRedeem', () => { + expect( + resolveLockedRequestedWithdrawShares({ + requestedLockedAssets: 25_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_000000000000000000n, + previewWithdrawShares: 60_000000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(49_000000000000000000n) + }) +}) + +describe('cooldown max selection', () => { + it('derives the exact cooldown asset cap from the locked share balance', () => { + expect( + getLockedCooldownMaxAssetAmount({ + lockedWalletShares: 50_000000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) + + it('derives the displayed cooldown max amount from the exact cooldown asset cap', () => { + expect( + getLockedCooldownMaxDisplayAmount({ + maxCooldownAssetAmount: 50_000000000000000000n, + isLockedUnderlyingDisplay: true, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(51_000000n) + }) + + it('snaps a pre-cooldown max input back to the exact cooldown asset cap', () => { + expect( + resolveLockedRequestedAmountFromInput({ + amount: 51_000000n, + inputUnit: 'underlying', + canWithdrawNow: false, + needsCooldownStart: true, + maxCooldownDisplayAmount: 51_000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawLockedAssets: 49_999999999999999999n, + lockedDisplayPricePerShare: 1_020000n, + lockedVaultTokenDecimals: 18, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw for partial pre-cooldown underlying input when available', () => { + expect( + resolveLockedRequestedAmountFromInput({ + amount: 25_000000n, + inputUnit: 'underlying', + canWithdrawNow: false, + needsCooldownStart: true, + maxCooldownDisplayAmount: 51_000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawLockedAssets: 24_600000000000000000n, + lockedDisplayPricePerShare: 1_020000n, + lockedVaultTokenDecimals: 18, + unlockedPricePerShare: 1_020000n, + unlockedVaultDecimals: 18 + }) + ).toBe(24_600000000000000000n) + }) + + it('starts cooldown with the full share balance when the exact cooldown asset cap is selected', () => { + expect( + resolveCooldownSharesToStart({ + needsCooldownStart: true, + lockedRequestedAmountRaw: 50_000000000000000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawShares: 49_999999999999999999n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18, + lockedWalletShares: 50_000000000000000000n + }) + ).toBe(50_000000000000000000n) + }) + + it('uses previewWithdraw shares for partial cooldown selection when available', () => { + expect( + resolveCooldownSharesToStart({ + needsCooldownStart: true, + lockedRequestedAmountRaw: 24_600000000000000000n, + maxCooldownAssetAmount: 50_000000000000000000n, + previewWithdrawShares: 23_700000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18, + lockedWalletShares: 50_000000000000000000n + }) + ).toBe(23_700000000000000000n) + }) +}) + +describe('resolveLockedRedeemAssets', () => { + it('uses previewRedeem for partial locked-leg share redemptions when it is available', () => { + expect( + resolveLockedRedeemAssets({ + requestedLockedShares: 25_000000000000000000n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + previewRedeemAssets: 24_800000000000000000n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(24_800000000000000000n) + }) + + it('uses the authoritative maxWithdraw assets for an exact max redeem', () => { + expect( + resolveLockedRedeemAssets({ + requestedLockedShares: 49_999999999999999999n, + maxWithdrawAssets: 50_000000000000000000n, + maxRedeemShares: 49_999999999999999999n, + lockedPricePerShare: 1_000000000000000000n, + lockedVaultTokenDecimals: 18 + }) + ).toBe(50_000000000000000000n) + }) +}) diff --git a/src/test/cooldownUtils.test.ts b/src/test/cooldownUtils.test.ts new file mode 100644 index 000000000..cb33bc8b2 --- /dev/null +++ b/src/test/cooldownUtils.test.ts @@ -0,0 +1,22 @@ +import { formatDays, resolveDurationSeconds } from '@pages/vaults/components/widget/yvUSD/cooldownUtils' +import { describe, expect, it } from 'vitest' + +describe('resolveDurationSeconds', () => { + it('uses the contract-provided duration when available', () => { + expect(resolveDurationSeconds(7n * 86_400n, 5)).toBe(604800) + }) + + it('falls back to the configured number of days when contract data is unavailable', () => { + expect(resolveDurationSeconds(undefined, 5)).toBe(432000) + }) +}) + +describe('formatDays', () => { + it('formats the 5-day withdrawal fallback label correctly', () => { + expect(formatDays(resolveDurationSeconds(undefined, 5), 5)).toBe('5 days') + }) + + it('formats contract-provided withdrawal windows without forcing the fallback value', () => { + expect(formatDays(resolveDurationSeconds(7n * 86_400n, 5), 5)).toBe('7 days') + }) +}) diff --git a/src/components/shared/utils/format.test.ts b/src/test/format.test.ts similarity index 96% rename from src/components/shared/utils/format.test.ts rename to src/test/format.test.ts index b6a14cb51..d101db1d7 100644 --- a/src/components/shared/utils/format.test.ts +++ b/src/test/format.test.ts @@ -1,5 +1,5 @@ +import { formatTAmount } from '@shared/utils/format' import { afterEach, describe, expect, it } from 'vitest' -import { formatTAmount } from './format' const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') diff --git a/src/test/kongVaultSelectors.test.ts b/src/test/kongVaultSelectors.test.ts new file mode 100644 index 000000000..3582bd615 --- /dev/null +++ b/src/test/kongVaultSelectors.test.ts @@ -0,0 +1,45 @@ +import { getVaultAPR } from '@pages/vaults/domain/kongVaultSelectors' +import { describe, expect, it } from 'vitest' + +describe('getVaultAPR', () => { + it('uses list pricePerShare when snapshot pricePerShare is missing', () => { + const apr = getVaultAPR({ + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + name: 'Vault', + symbol: 'yvTEST', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'USDC', + symbol: 'USDC', + decimals: 6 + }, + tvl: 1000, + performance: { + oracle: { apr: 0.02, apy: 0.02 }, + estimated: { apr: 0.02, apy: 0.02, type: 'oracle', components: {} }, + historical: { net: 0.01, weeklyNet: 0.01, monthlyNet: 0.01, inceptionNet: 0.01 } + }, + fees: { + managementFee: 0, + performanceFee: 0 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 1, + riskLevel: 1, + staking: null, + pricePerShare: '1050000' + } as any) + + expect(apr.pricePerShare.today).toBeCloseTo(1.05, 8) + }) +}) diff --git a/src/test/stakingWithdrawable.test.ts b/src/test/stakingWithdrawable.test.ts new file mode 100644 index 000000000..acb3d57c7 --- /dev/null +++ b/src/test/stakingWithdrawable.test.ts @@ -0,0 +1,85 @@ +import { getStakingWithdrawableAssets } from '@pages/vaults/hooks/actions/stakingAdapter' +import { describe, expect, it } from 'vitest' + +describe('getStakingWithdrawableAssets', () => { + it('prefers maxRedeem + convertToAssets for ERC4626 wrappers', async () => { + const result = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') return 99n + if (functionName === 'convertToAssets') return 150n + if (functionName === 'maxWithdraw') return 123n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(150n) + }) + + it('falls back to maxWithdraw when maxRedeem conversion is unavailable', async () => { + const result = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') throw new Error('missing') + if (functionName === 'maxWithdraw') return 123n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(123n) + }) + + it('falls back to convertToAssets then raw balance when maxRedeem and maxWithdraw both fail', async () => { + const converted = await getStakingWithdrawableAssets({ + read: async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') throw new Error('missing') + if (functionName === 'maxWithdraw') throw new Error('missing') + if (functionName === 'convertToAssets') return 456n + return 0n + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(converted).toBe(456n) + + const fallback = await getStakingWithdrawableAssets({ + read: async () => { + throw new Error('missing') + }, + stakingAddress: '0x2222222222222222222222222222222222222222', + account: '0x1111111111111111111111111111111111111111', + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(fallback).toBe(99n) + }) +}) diff --git a/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts b/src/test/strategiesPercentFormat.test.ts similarity index 88% rename from src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts rename to src/test/strategiesPercentFormat.test.ts index 896b97150..50fe387b1 100644 --- a/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts +++ b/src/test/strategiesPercentFormat.test.ts @@ -1,7 +1,6 @@ +import { formatStrategiesApy, formatStrategiesPercent } from '@pages/vaults/components/detail/strategiesPercentFormat' import { describe, expect, it } from 'vitest' -import { formatStrategiesApy, formatStrategiesPercent } from './strategiesPercentFormat' - describe('strategiesPercentFormat', () => { it('pads percentages to the strategy card precision rules', () => { expect(formatStrategiesPercent(12.34)).toBe('12.3%') diff --git a/src/components/pages/vaults/hooks/useVaultApyData.test.ts b/src/test/useVaultApyData.test.ts similarity index 96% rename from src/components/pages/vaults/hooks/useVaultApyData.test.ts rename to src/test/useVaultApyData.test.ts index 5814bdf21..15766eb8f 100644 --- a/src/components/pages/vaults/hooks/useVaultApyData.test.ts +++ b/src/test/useVaultApyData.test.ts @@ -1,6 +1,6 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { computeKatanaTotalApr, resolveKatanaExtras } from '@pages/vaults/hooks/useVaultApyData' import { describe, expect, it } from 'vitest' -import { computeKatanaTotalApr, resolveKatanaExtras } from './useVaultApyData' const DETAIL_VAULT_WITH_COMPONENTS = { version: '3.0.0', diff --git a/src/components/shared/hooks/useVaultFilterUtils.test.ts b/src/test/useVaultFilterUtils.test.ts similarity index 80% rename from src/components/shared/hooks/useVaultFilterUtils.test.ts rename to src/test/useVaultFilterUtils.test.ts index b749b5f85..955e4e774 100644 --- a/src/components/shared/hooks/useVaultFilterUtils.test.ts +++ b/src/test/useVaultFilterUtils.test.ts @@ -1,5 +1,5 @@ +import { getVaultHoldingsUsdValue } from '@shared/hooks/useVaultFilterUtils' import { describe, expect, it } from 'vitest' -import { getVaultHoldingsUsdValue, matchesSelectedChains } from './useVaultFilterUtils' const VAULT_ADDRESS = '0x8589462548984c5C0f2C0140FB276351B5a77fe1' const ASSET_ADDRESS = '0x0000000000000000000000000000000000000002' @@ -69,15 +69,3 @@ describe('getVaultHoldingsUsdValue', () => { expect(value).toBeCloseTo(2.1, 8) }) }) - -describe('matchesSelectedChains', () => { - it('treats null or empty selections as all chains', () => { - expect(matchesSelectedChains(1, null)).toBe(true) - expect(matchesSelectedChains(1, [])).toBe(true) - }) - - it('only matches vaults from the selected chains', () => { - expect(matchesSelectedChains(1, [1])).toBe(true) - expect(matchesSelectedChains(10, [1])).toBe(false) - }) -}) diff --git a/src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts b/src/test/useYvUsdCharts.helpers.test.ts similarity index 97% rename from src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts rename to src/test/useYvUsdCharts.helpers.test.ts index d2f90f95b..88b325427 100644 --- a/src/components/pages/vaults/hooks/useYvUsdCharts.helpers.test.ts +++ b/src/test/useYvUsdCharts.helpers.test.ts @@ -1,5 +1,5 @@ +import { buildApyDataFromPpsSeries, buildUnderlyingLockedPpsSeries } from '@pages/vaults/hooks/useYvUsdCharts.helpers' import { describe, expect, it } from 'vitest' -import { buildApyDataFromPpsSeries, buildUnderlyingLockedPpsSeries } from './useYvUsdCharts.helpers' describe('buildUnderlyingLockedPpsSeries', () => { it('converts locked PPS into underlying terms using the unlocked PPS for the same date', () => { diff --git a/src/test/useYvUsdVaults.helpers.test.ts b/src/test/useYvUsdVaults.helpers.test.ts new file mode 100644 index 000000000..783d474b4 --- /dev/null +++ b/src/test/useYvUsdVaults.helpers.test.ts @@ -0,0 +1,20 @@ +import { getYvUsdTvlBreakdown } from '@pages/vaults/hooks/useYvUsdVaults.helpers' +import { describe, expect, it } from 'vitest' + +describe('getYvUsdTvlBreakdown', () => { + it('treats the unlocked vault TVL as total and derives the unlocked remainder after locked TVL', () => { + expect(getYvUsdTvlBreakdown({ totalTvl: 1000, lockedTvl: 500 })).toEqual({ + totalTvl: 1000, + unlockedTvl: 500, + lockedTvl: 500 + }) + }) + + it('clamps the unlocked remainder at zero when locked TVL exceeds total TVL', () => { + expect(getYvUsdTvlBreakdown({ totalTvl: 100, lockedTvl: 120 })).toEqual({ + totalTvl: 100, + unlockedTvl: 0, + lockedTvl: 120 + }) + }) +}) diff --git a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts b/src/test/valuation.test.ts similarity index 96% rename from src/components/pages/vaults/components/widget/deposit/valuation.test.ts rename to src/test/valuation.test.ts index d6a00e6fd..54fc79225 100644 --- a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts +++ b/src/test/valuation.test.ts @@ -1,5 +1,8 @@ +import { + calculateDepositValueInfo, + resolveValuationShareCount +} from '@pages/vaults/components/widget/deposit/valuation' import { describe, expect, it } from 'vitest' -import { calculateDepositValueInfo, resolveValuationShareCount } from './valuation' const VAULT = '0x0000000000000000000000000000000000000001' const STAKING = '0x0000000000000000000000000000000000000002' diff --git a/src/components/shared/utils/vaultApy.test.ts b/src/test/vaultApy.test.ts similarity index 99% rename from src/components/shared/utils/vaultApy.test.ts rename to src/test/vaultApy.test.ts index f7da728ce..08ce8f78e 100644 --- a/src/components/shared/utils/vaultApy.test.ts +++ b/src/test/vaultApy.test.ts @@ -1,11 +1,11 @@ import type { TKongVault, TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { describe, expect, it } from 'vitest' import { calculateKatanaThirtyDayAPY, calculateVaultEstimatedAPY, calculateVaultHistoricalAPY, getKatanaAprData -} from './vaultApy' +} from '@shared/utils/vaultApy' +import { describe, expect, it } from 'vitest' const BASE_VAULT: TKongVault = { chainId: 747474, diff --git a/src/components/pages/vaults/utils/yvUsd.test.ts b/src/test/yvUsd.test.ts similarity index 85% rename from src/components/pages/vaults/utils/yvUsd.test.ts rename to src/test/yvUsd.test.ts index e3f4830d9..cfcf8b38c 100644 --- a/src/components/pages/vaults/utils/yvUsd.test.ts +++ b/src/test/yvUsd.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest' import { calculateHistoricalAprFromPricePerShares, calculateHistoricalApyFromPricePerShares, @@ -9,11 +8,9 @@ import { convertYvUsdVariantAmountString, convertYvUsdVariantRawAmount, getWeightedYvUsdApy, - getYvUsdLockedWithdrawDisplayMode, - getYvUsdUnderlyingPricePerShare, - YVUSD_CUSTOM_RISK_SCORE, - YVUSD_RISK_SCORE_ITEMS -} from './yvUsd' + getYvUsdUnderlyingPricePerShare +} from '@pages/vaults/utils/yvUsd' +import { describe, expect, it } from 'vitest' describe('getWeightedYvUsdApy', () => { it('returns the unlocked APY when only unlocked value is present', () => { @@ -72,22 +69,6 @@ describe('getWeightedYvUsdApy', () => { }) }) -describe('yvUSD risk override', () => { - it('uses the provisional custom score for the detail risk section', () => { - expect(YVUSD_CUSTOM_RISK_SCORE).toBe('3/5') - expect(YVUSD_RISK_SCORE_ITEMS[0]?.score).toBe('3/5') - }) - - it('keeps the current published risk sections intact', () => { - expect(YVUSD_RISK_SCORE_ITEMS.map((item) => item.label)).toEqual([ - 'Overall Risk Score', - 'Leverage Looping', - 'Duration and PT Strategies', - 'Cross-Chain Routing' - ]) - }) -}) - describe('yvUSD variant amount conversion', () => { const unlockedPricePerShare = 1_050_000n const unlockedVaultDecimals = 18 @@ -240,13 +221,3 @@ describe('yvUSD historical PPS normalization', () => { ).toBe(0) }) }) - -describe('yvUSD locked withdraw display mode', () => { - it('uses underlying display mode when the helper is called with Enso enabled', () => { - expect(getYvUsdLockedWithdrawDisplayMode(true)).toBe('underlying') - }) - - it('keeps underlying display mode even when Enso is unavailable', () => { - expect(getYvUsdLockedWithdrawDisplayMode(false)).toBe('underlying') - }) -}) From f0c190a9fc33a1a66448921c272eaea25e040226 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:15:20 +0100 Subject: [PATCH 13/15] chore: bring back transaction overlay and URL tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/test/transactionOverlay.test.ts | 101 ++++++++++++++++++++++++++++ src/test/urlUtils.test.ts | 73 ++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/test/transactionOverlay.test.ts create mode 100644 src/test/urlUtils.test.ts diff --git a/src/test/transactionOverlay.test.ts b/src/test/transactionOverlay.test.ts new file mode 100644 index 000000000..cb611acf6 --- /dev/null +++ b/src/test/transactionOverlay.test.ts @@ -0,0 +1,101 @@ +import { + resolveCompletionDeferral, + shouldAutoContinuePermitSuccess +} from '@pages/vaults/components/widget/shared/transactionOverlay.helpers' +import { describe, expect, it } from 'vitest' + +describe('shouldAutoContinuePermitSuccess', () => { + it('continues permit steps once the next step is ready', () => { + expect( + shouldAutoContinuePermitSuccess({ + overlayState: 'success', + executedStepIsPermit: true, + executedStepAutoContinues: true, + executedStepCompletesFlow: false, + currentStepLabel: 'Deposit', + executedStepLabel: 'Sign Permit', + isStepReady: true, + hasAdvancedFromStep: null, + hasAutoContinuedFromStep: null + }) + ).toBe(true) + }) + + it('does not continue permit steps before the next step changes and becomes ready', () => { + expect( + shouldAutoContinuePermitSuccess({ + overlayState: 'success', + executedStepIsPermit: true, + executedStepAutoContinues: true, + executedStepCompletesFlow: false, + currentStepLabel: 'Sign Permit', + executedStepLabel: 'Sign Permit', + isStepReady: false, + hasAdvancedFromStep: null, + hasAutoContinuedFromStep: null + }) + ).toBe(false) + }) + + it('does not continue terminal permit steps', () => { + expect( + shouldAutoContinuePermitSuccess({ + overlayState: 'success', + executedStepIsPermit: true, + executedStepAutoContinues: true, + executedStepCompletesFlow: true, + currentStepLabel: 'Done', + executedStepLabel: 'Sign Permit', + isStepReady: true, + hasAdvancedFromStep: null, + hasAutoContinuedFromStep: null + }) + ).toBe(false) + }) +}) + +describe('resolveCompletionDeferral', () => { + it('does not run completion callbacks for non-terminal success states', () => { + expect( + resolveCompletionDeferral({ + completedAllSteps: false, + deferOnAllCompleteUntilClose: false, + deferOnAllCompleteUntilConfettiEnd: true, + stepShowsConfetti: true + }) + ).toBe('none') + }) + + it('prefers close deferral when explicitly requested', () => { + expect( + resolveCompletionDeferral({ + completedAllSteps: true, + deferOnAllCompleteUntilClose: true, + deferOnAllCompleteUntilConfettiEnd: true, + stepShowsConfetti: true + }) + ).toBe('after-close') + }) + + it('defers terminal completion until confetti ends when configured', () => { + expect( + resolveCompletionDeferral({ + completedAllSteps: true, + deferOnAllCompleteUntilClose: false, + deferOnAllCompleteUntilConfettiEnd: true, + stepShowsConfetti: true + }) + ).toBe('after-confetti') + }) + + it('falls back to immediate completion when no confetti is shown', () => { + expect( + resolveCompletionDeferral({ + completedAllSteps: true, + deferOnAllCompleteUntilClose: false, + deferOnAllCompleteUntilConfettiEnd: true, + stepShowsConfetti: false + }) + ).toBe('immediate') + }) +}) diff --git a/src/test/urlUtils.test.ts b/src/test/urlUtils.test.ts new file mode 100644 index 000000000..e4e2f90aa --- /dev/null +++ b/src/test/urlUtils.test.ts @@ -0,0 +1,73 @@ +import { isCurveHostUrl, normalizeCurveUrl } from '@shared/utils/curveUrlUtils' +import { isVaultsIndexPath, normalizePathname } from '@shared/utils/routes' +import { describe, expect, it } from 'vitest' + +// --------------------------------------------------------------------------- +// Curve URL normalization +// --------------------------------------------------------------------------- + +describe('normalizeCurveUrl', () => { + it('canonicalizes curve.fi to www.curve.finance', () => { + expect(normalizeCurveUrl('https://curve.fi/pools')).toBe('https://www.curve.finance/pools') + }) + + it('canonicalizes curve.finance to www.curve.finance', () => { + expect(normalizeCurveUrl('https://curve.finance/pools')).toBe('https://www.curve.finance/pools') + }) + + it('keeps canonical host unchanged', () => { + expect(normalizeCurveUrl('https://www.curve.finance/pools')).toBe('https://www.curve.finance/pools') + }) + + it('returns empty string for invalid urls', () => { + expect(normalizeCurveUrl('not-a-url')).toBe('') + }) + + it('returns empty string for non-http schemes', () => { + expect(normalizeCurveUrl('javascript://curve.fi/%0Aalert(1)')).toBe('') + expect(normalizeCurveUrl('ftp://curve.fi/pools')).toBe('') + }) +}) + +describe('isCurveHostUrl', () => { + it('accepts allowed curve hosts', () => { + expect(isCurveHostUrl('https://curve.fi/pools')).toBe(true) + expect(isCurveHostUrl('https://www.curve.fi/pools')).toBe(true) + expect(isCurveHostUrl('https://curve.finance/pools')).toBe(true) + expect(isCurveHostUrl('https://www.curve.finance/pools')).toBe(true) + }) + + it('rejects non-curve hosts and invalid urls', () => { + expect(isCurveHostUrl('https://example.com/pools')).toBe(false) + expect(isCurveHostUrl('not-a-url')).toBe(false) + }) + + it('rejects non-http schemes even for allowed curve hosts', () => { + expect(isCurveHostUrl('javascript://curve.fi/%0Aalert(1)')).toBe(false) + expect(isCurveHostUrl('ftp://www.curve.finance/pools')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Route pathname normalization +// --------------------------------------------------------------------------- + +describe('normalizePathname', () => { + it('keeps root pathname', () => { + expect(normalizePathname('/')).toBe('/') + }) + + it('removes trailing slashes', () => { + expect(normalizePathname('/vaults/')).toBe('/vaults') + expect(normalizePathname('/vaults///')).toBe('/vaults') + }) +}) + +describe('isVaultsIndexPath', () => { + it('matches only /vaults (after normalization)', () => { + expect(isVaultsIndexPath('/vaults')).toBe(true) + expect(isVaultsIndexPath('/vaults/')).toBe(true) + expect(isVaultsIndexPath('/vaults/1/0x0')).toBe(false) + expect(isVaultsIndexPath('/vaults-beta')).toBe(false) + }) +}) From 1a06b5768c5bbfe89e4eb0767857e112cb10f864 Mon Sep 17 00:00:00 2001 From: 0xeye <97349378+0xeye@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:21:09 +0100 Subject: [PATCH 14/15] chore: update CLAUDE.md --- CLAUDE.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 262d6e56d..590a7c2e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,12 +25,15 @@ Husky runs `lint-staged` + `bun run tslint` on every commit. ## Testing -All tests live in `src/test/` — focused on math and calculations where a wrong decimal silently loses user funds. Expected values should be human-verified independently of the code. Do not test boolean flag logic, string mapping, config assertions, or control flow — an AI will just adjust expected values to match the implementation, making those tests circular. +All tests live in `src/test/` — never mirror the source tree 1:1. Group related tests into a single file per domain. -**Deprioritised:** UI/component tests (`render`, `screen`, `@testing-library/react`). Low value because when AI writes both the implementation and the tests, it validates its own assumptions against itself — when the implementation changes, the tests get rewritten to match, so they never actually catch anything. +**Priority: math and calculations.** APY/APR, price impact, share/asset conversions, bigint math, formatting, duration math. A wrong decimal silently loses user funds. Expected values must be human-verified independently of the code — if an AI would just read the implementation and write the expected value to match, the test is worthless. -- Never mirror the source tree 1:1. Group related calculations into a single test file per domain. -- Never create a new test file for a single helper — find the test file it belongs in, or create one if a new domain is emerging +**Allowed: safety-critical logic.** Transaction state machines (skipping steps or double-submitting costs funds), URL validation (XSS vectors, misdirected pool links). These are kept because the consequences of a bug are severe and not immediately visible. + +**Deprioritised: UI/component tests.** Do not use `render`, `screen`, or `@testing-library/react`. When AI writes both the implementation and the tests, it validates its own assumptions against itself — when the implementation changes, the tests get rewritten to match, so they never actually catch anything. + +**Do not test:** config assertions, boolean flag logic, string/label mapping, route resolution, schema validation, data selection/preference, viewport/layout (Tailwind standardizes breakpoints), ABI construction, balance partitioning. These are all patterns where the expected value has no independent source of truth. ## Code Style From 7f84c452705c691a460528968e34ceff17a30d2c Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Sat, 16 May 2026 16:07:42 +0000 Subject: [PATCH 15/15] Lint --- .env.example | 1 + scripts/tenderly-vnet-status.test.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index b364548b3..2fb19507f 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ PERSONAL_TENDERLY_API_KEY= PERSONAL_ACCOUNT_SLUG= PERSONAL_PROJECT_SLUG= PERSONAL_TENDERLY_RPC_NAME= +TENDERLY_TEST_TX_FROM_ADDRESS= VITE_ALCHEMY_KEY= VITE_INFURA_PROJECT_ID= diff --git a/scripts/tenderly-vnet-status.test.ts b/scripts/tenderly-vnet-status.test.ts index dbb2ddf2e..f0b225b36 100644 --- a/scripts/tenderly-vnet-status.test.ts +++ b/scripts/tenderly-vnet-status.test.ts @@ -10,6 +10,8 @@ import { selectMatchingTenderlyVnet } from './tenderly-vnet-status' +const TEST_TX_FROM_ADDRESS = process.env.TENDERLY_TEST_TX_FROM_ADDRESS ?? '0x2222222222222222222222222222222222222222' + describe('tenderly-vnet-status', () => { it('defaults to the webops profile', () => { expect( @@ -137,7 +139,7 @@ describe('tenderly-vnet-status', () => { createdAtAgeLabel: '8s', blockNumber: 24_735_515, txHash: '0xb00dc057e50c8926896495cb31717bfa7ab47608673789b4b7b478ec114080cb', - from: '0x5b0d3243c78fb9d4ac035fb2384ffdf7a9ef6396', + from: 'TEST_TX_FROM_ADDRESS', to: '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' } ],