diff --git a/apps/scan/src/app/(home)/(overview)/_components/heading.tsx b/apps/scan/src/app/(home)/(overview)/_components/heading.tsx index 99e14ac0e..c8f2a3a9d 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/heading.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/heading.tsx @@ -1,3 +1,5 @@ +'use client'; + import Link from 'next/link'; import { Plus } from 'lucide-react'; @@ -8,12 +10,31 @@ import { Button } from '@/components/ui/button'; import { Logo } from '@/components/logo'; import { SearchButton } from './search-button'; +import { useState } from 'react'; export const HomeHeading = () => { + const [clickCount, setClickCount] = useState(0); + + const handleLogoClick = () => { + const newCount = clickCount + 1; + setClickCount(newCount); + + if (newCount === 5) { + // Dispatch custom event to open modal + window.dispatchEvent(new CustomEvent('open-verified-filter-modal')); + setClickCount(0); // Reset counter + } + + // Reset counter after 1 second of no clicks + setTimeout(() => { + setClickCount(0); + }, 1000); + }; + return (
-
+

x402scan

diff --git a/apps/scan/src/app/(home)/(overview)/_components/sellers/all-sellers/table.tsx b/apps/scan/src/app/(home)/(overview)/_components/sellers/all-sellers/table.tsx index d133c46b2..360a4c8b5 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/sellers/all-sellers/table.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/sellers/all-sellers/table.tsx @@ -8,12 +8,14 @@ import { columns } from './columns'; import { useSellersSorting } from '../../../../../_contexts/sorting/sellers/hook'; import { useTimeRangeContext } from '@/app/_contexts/time-range/hook'; import { useChain } from '@/app/_contexts/chain/hook'; +import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook'; import { useState } from 'react'; export const AllSellersTable = () => { const { sorting } = useSellersSorting(); const { timeframe } = useTimeRangeContext(); const { chain } = useChain(); + const { verifiedOnly } = useVerifiedFilter(); const [page, setPage] = useState(0); const pageSize = 10; @@ -25,6 +27,7 @@ export const AllSellersTable = () => { page, }, timeframe, + verifiedOnly, }); return ( diff --git a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/columns.tsx b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/columns.tsx index 6ff5985df..91475cf0c 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/columns.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/columns.tsx @@ -37,13 +37,12 @@ export const columns: ExtendedColumnDef[] = [ ), cell: ({ row }) => { return ( -
- -
+ ); }, size: 225, diff --git a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/container.tsx b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/container.tsx new file mode 100644 index 000000000..a12fb79c7 --- /dev/null +++ b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/container.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Section } from '@/app/_components/layout/page-utils'; +import { RangeSelector } from '@/app/_contexts/time-range/component'; + +export const TopServersContainer = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( +
+ +
+ } + > + {children} + + ); +}; diff --git a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/index.tsx b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/index.tsx index 28ed65e3d..03ca2ddff 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/index.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/index.tsx @@ -2,9 +2,8 @@ import { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { Section } from '@/app/_components/layout/page-utils'; - import { KnownSellersTable, LoadingKnownSellersTable } from './table'; +import { TopServersContainer } from './container'; import { api, HydrateClient } from '@/trpc/server'; @@ -12,7 +11,6 @@ import { defaultSellersSorting } from '@/app/_contexts/sorting/sellers/default'; import { SellersSortingProvider } from '@/app/_contexts/sorting/sellers/provider'; import { TimeRangeProvider } from '@/app/_contexts/time-range/provider'; -import { RangeSelector } from '@/app/_contexts/time-range/component'; import { ActivityTimeframe } from '@/types/timeframes'; @@ -60,19 +58,3 @@ export const LoadingTopServers = () => { ); }; - -const TopServersContainer = ({ children }: { children: React.ReactNode }) => { - return ( -
- -
- } - > - {children} - - ); -}; diff --git a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/table.tsx b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/table.tsx index 1b5c9e8cc..9e42b9c9c 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/table.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/sellers/known-sellers/table.tsx @@ -5,6 +5,7 @@ import { DataTable } from '@/components/ui/data-table'; import { useSellersSorting } from '@/app/_contexts/sorting/sellers/hook'; import { useTimeRangeContext } from '@/app/_contexts/time-range/hook'; import { useChain } from '@/app/_contexts/chain/hook'; +import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook'; import { columns } from './columns'; import { api } from '@/trpc/client'; @@ -13,6 +14,7 @@ export const KnownSellersTable = () => { const { sorting } = useSellersSorting(); const { timeframe } = useTimeRangeContext(); const { chain } = useChain(); + const { verifiedOnly } = useVerifiedFilter(); const [topSellers] = api.public.sellers.bazaar.list.useSuspenseQuery({ chain, @@ -21,6 +23,7 @@ export const KnownSellersTable = () => { }, timeframe, sorting, + verifiedOnly, }); return ; diff --git a/apps/scan/src/app/(home)/(overview)/_components/stats/charts.tsx b/apps/scan/src/app/(home)/(overview)/_components/stats/charts.tsx index ea1fef295..42d91e5f3 100644 --- a/apps/scan/src/app/(home)/(overview)/_components/stats/charts.tsx +++ b/apps/scan/src/app/(home)/(overview)/_components/stats/charts.tsx @@ -3,6 +3,7 @@ import { api } from '@/trpc/client'; import { useTimeRangeContext } from '@/app/_contexts/time-range/hook'; +import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook'; import { LoadingOverallStatsCard, OverallStatsCard } from './card'; @@ -14,16 +15,19 @@ import { useChain } from '@/app/_contexts/chain/hook'; export const OverallCharts = () => { const { timeframe } = useTimeRangeContext(); const { chain } = useChain(); + const { verifiedOnly } = useVerifiedFilter(); const [overallStats] = api.public.stats.overall.useSuspenseQuery({ chain, timeframe, + verifiedOnly, }); const [bucketedStats] = api.public.stats.bucketed.useSuspenseQuery({ numBuckets: 48, timeframe, chain, + verifiedOnly, }); const chartData: ChartData<{ diff --git a/apps/scan/src/app/(home)/(overview)/_components/verified-filter-wrapper.tsx b/apps/scan/src/app/(home)/(overview)/_components/verified-filter-wrapper.tsx new file mode 100644 index 000000000..e83f49404 --- /dev/null +++ b/apps/scan/src/app/(home)/(overview)/_components/verified-filter-wrapper.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { VerifiedFilterProvider } from '@/app/_contexts/verified-filter/provider'; +import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { CheckCircle } from 'lucide-react'; + +export const VerifiedFilterWrapper = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( + + + {children} + + ); +}; + +// Reusable toggle switch component +const VerifiedToggleSwitch = () => { + const { verifiedOnly, setVerifiedOnly } = useVerifiedFilter(); + + const handleToggle = () => { + setVerifiedOnly(!verifiedOnly); + }; + + return ( +
+ + +
+ ); +}; + +// Dialog with keyboard shortcut (triple tap 'v') and logo click (5 times) +const VerifiedFilterDialog = () => { + const [showModal, setShowModal] = useState(false); + + // Keyboard shortcut (triple tap 'v') + useEffect(() => { + let tapCount = 0; + let tapTimeout: NodeJS.Timeout; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'v' || e.key === 'V') { + tapCount++; + + // Clear existing timeout + if (tapTimeout) { + clearTimeout(tapTimeout); + } + + // Open modal on third tap + if (tapCount === 3) { + e.preventDefault(); + setShowModal(true); + tapCount = 0; + } else { + // Reset counter after 500ms of no taps + tapTimeout = setTimeout(() => { + tapCount = 0; + }, 500); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + if (tapTimeout) { + clearTimeout(tapTimeout); + } + }; + }, []); + + // Listen for custom event from logo clicks + useEffect(() => { + const handleOpenModal = () => { + setShowModal(true); + }; + + window.addEventListener('open-verified-filter-modal', handleOpenModal); + return () => { + window.removeEventListener('open-verified-filter-modal', handleOpenModal); + }; + }, []); + + return ( + + + + + + Verified Servers Filter + + + Filter servers to only show those with verified accepts. Volume and + metrics are recalculated to only include transactions to verified + addresses. + + + + + + ); +}; diff --git a/apps/scan/src/app/(home)/_components/transactions/table.tsx b/apps/scan/src/app/(home)/_components/transactions/table.tsx index 4e815c848..cf5b0fef6 100644 --- a/apps/scan/src/app/(home)/_components/transactions/table.tsx +++ b/apps/scan/src/app/(home)/_components/transactions/table.tsx @@ -8,6 +8,7 @@ import { DataTable } from '@/components/ui/data-table'; import { useChain } from '@/app/_contexts/chain/hook'; import { useTransfersSorting } from '@/app/_contexts/sorting/transfers/hook'; +import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook'; import { columns } from './columns'; @@ -20,6 +21,7 @@ interface Props { export const Table: React.FC = ({ pageSize }) => { const { sorting } = useTransfersSorting(); const { chain } = useChain(); + const { verifiedOnly } = useVerifiedFilter(); const [page, setPage] = useState(0); @@ -31,6 +33,7 @@ export const Table: React.FC = ({ pageSize }) => { }, sorting, timeframe: ActivityTimeframe.ThirtyDays, + verifiedOnly, }); return ( diff --git a/apps/scan/src/app/(home)/layout.tsx b/apps/scan/src/app/(home)/layout.tsx index e0a113c03..ab839b1d7 100644 --- a/apps/scan/src/app/(home)/layout.tsx +++ b/apps/scan/src/app/(home)/layout.tsx @@ -1,5 +1,6 @@ import { Nav } from '../_components/layout/nav'; import { Footer } from '../_components/layout/footer'; +import { VerifiedFilterWrapper } from './(overview)/_components/verified-filter-wrapper'; export default function HomeLayout({ children, @@ -7,44 +8,46 @@ export default function HomeLayout({ children: React.ReactNode; }) { return ( -
-
+ +
+
+
); } diff --git a/apps/scan/src/app/_components/origins.tsx b/apps/scan/src/app/_components/origins.tsx index 7787b384b..96bdda12d 100644 --- a/apps/scan/src/app/_components/origins.tsx +++ b/apps/scan/src/app/_components/origins.tsx @@ -1,4 +1,4 @@ -import { Globe } from 'lucide-react'; +import { Globe, CheckCircle } from 'lucide-react'; import Link from 'next/link'; @@ -20,12 +20,14 @@ interface Props { addresses: MixedAddress[]; origins: ResourceOrigin[]; disableCopy?: boolean; + hasVerifiedAccept?: boolean; } export const Origins: React.FC = ({ origins, addresses, disableCopy, + hasVerifiedAccept, }) => { if (!origins || origins.length === 0) { if (addresses.length === 0) { @@ -55,6 +57,7 @@ export const Origins: React.FC = ({ ) } + hasVerifiedAccept={hasVerifiedAccept} /> ); @@ -104,6 +107,7 @@ export const Origins: React.FC = ({ ) } + hasVerifiedAccept={hasVerifiedAccept} /> ); }; @@ -154,19 +158,50 @@ export const OriginsSkeleton = () => { ); }; +const VerifiedBadge = () => { + return ( + + + + + + + + + ); +}; + interface OriginsContainerProps { Icon: ({ className }: { className: string }) => React.ReactNode; title: React.ReactNode; address: React.ReactNode; + hasVerifiedAccept?: boolean; } -const OriginsContainer = ({ Icon, title, address }: OriginsContainerProps) => { +const OriginsContainer = ({ + Icon, + title, + address, + hasVerifiedAccept, +}: OriginsContainerProps) => { return (
-
+
{title} + {hasVerifiedAccept && }
{address}
diff --git a/apps/scan/src/app/_components/seller.tsx b/apps/scan/src/app/_components/seller.tsx index f3c0cbaf0..f8105a07e 100644 --- a/apps/scan/src/app/_components/seller.tsx +++ b/apps/scan/src/app/_components/seller.tsx @@ -49,11 +49,17 @@ export const Seller: React.FC = ({ ); } + // Check if any origin has a verified accept + const hasVerifiedAccept = origins.some( + origin => 'hasVerifiedAccept' in origin && origin.hasVerifiedAccept + ); + return ( ); }; diff --git a/apps/scan/src/app/_contexts/verified-filter/context.ts b/apps/scan/src/app/_contexts/verified-filter/context.ts new file mode 100644 index 000000000..9ba46e52e --- /dev/null +++ b/apps/scan/src/app/_contexts/verified-filter/context.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createContext } from 'react'; + +export interface VerifiedFilterContextType { + verifiedOnly: boolean; + setVerifiedOnly: (value: boolean) => void; +} + +export const VerifiedFilterContext = + createContext(null); diff --git a/apps/scan/src/app/_contexts/verified-filter/hook.ts b/apps/scan/src/app/_contexts/verified-filter/hook.ts new file mode 100644 index 000000000..f146248c1 --- /dev/null +++ b/apps/scan/src/app/_contexts/verified-filter/hook.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useContext } from 'react'; + +import { VerifiedFilterContext } from './context'; + +export const useVerifiedFilter = () => { + const context = useContext(VerifiedFilterContext); + if (!context) { + throw new Error( + 'useVerifiedFilter must be used within VerifiedFilterProvider' + ); + } + return context; +}; diff --git a/apps/scan/src/app/_contexts/verified-filter/provider.tsx b/apps/scan/src/app/_contexts/verified-filter/provider.tsx new file mode 100644 index 000000000..dcf222df9 --- /dev/null +++ b/apps/scan/src/app/_contexts/verified-filter/provider.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useState } from 'react'; + +import { VerifiedFilterContext } from './context'; + +const STORAGE_KEY = 'verified-filter-enabled'; + +export const VerifiedFilterProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + // Initialize state from localStorage + const [verifiedOnly, setVerifiedOnlyState] = useState(() => { + if (typeof window === 'undefined') return false; + const stored = localStorage.getItem(STORAGE_KEY); + return stored === 'true'; + }); + + // Persist to localStorage when changed + const setVerifiedOnly = (value: boolean) => { + setVerifiedOnlyState(value); + localStorage.setItem(STORAGE_KEY, String(value)); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/scan/src/services/db/bazaar/origins.ts b/apps/scan/src/services/db/bazaar/origins.ts index 49a9c22e5..7b5ca4519 100644 --- a/apps/scan/src/services/db/bazaar/origins.ts +++ b/apps/scan/src/services/db/bazaar/origins.ts @@ -19,18 +19,21 @@ const listBazaarOriginsUncached = async ( input: z.infer, pagination: z.infer ) => { - const originsByAddress = await getAcceptsAddresses({ + // When verifiedOnly is true, only get verified addresses + // This filters at the Accepts level before querying TransferEvents + const { addressToOrigins, originVerification } = await getAcceptsAddresses({ chain: input.chain, tags: input.tags, + verified: input.verifiedOnly ? true : undefined, }); - // Use uncached version since listBazaarOrigins is already cached - // This avoids creating a massive cache key with 100+ addresses + // Use the standard materialized view query + // When verifiedOnly is true, we already filtered to only verified addresses above const result = await listTopSellersMVUncached( { ...input, recipients: { - include: Object.keys(originsByAddress).map(addr => + include: Object.keys(addressToOrigins).map(addr => mixedAddressSchema.parse(addr) ), }, @@ -43,7 +46,7 @@ const listBazaarOriginsUncached = async ( string, { originId: string; - origins: (typeof originsByAddress)[string]; + origins: (typeof addressToOrigins)[string]; recipients: MixedAddress[]; facilitators: string[]; tx_count: number; @@ -51,11 +54,12 @@ const listBazaarOriginsUncached = async ( latest_block_timestamp: Date | null; unique_buyers: number; chains: Set; + hasVerifiedAccept: boolean; } >(); for (const item of result.items) { - const origins = originsByAddress[item.recipient]; + const origins = addressToOrigins[item.recipient]; if (!origins || origins.length === 0) continue; // Use the first origin's ID as the grouping key @@ -97,6 +101,7 @@ const listBazaarOriginsUncached = async ( latest_block_timestamp: item.latest_block_timestamp, unique_buyers: item.unique_buyers, chains: new Set(item.chains), + hasVerifiedAccept: originVerification.get(originId) ?? false, }); } } @@ -111,11 +116,12 @@ const listBazaarOriginsUncached = async ( latest_block_timestamp: item.latest_block_timestamp, unique_buyers: item.unique_buyers, chains: Array.from(item.chains), + hasVerifiedAccept: item.hasVerifiedAccept, })); return toPaginatedResponse({ items: groupedItems, - total_count: Object.keys(originsByAddress).length, + total_count: Object.keys(addressToOrigins).length, ...pagination, }); }; diff --git a/apps/scan/src/services/db/bazaar/schema.ts b/apps/scan/src/services/db/bazaar/schema.ts index 30b42e5ba..b23992e02 100644 --- a/apps/scan/src/services/db/bazaar/schema.ts +++ b/apps/scan/src/services/db/bazaar/schema.ts @@ -4,4 +4,5 @@ import { listTopSellersMVInputSchema } from '@/services/transfers/sellers/list-m export const listBazaarOriginsInputSchema = listTopSellersMVInputSchema.extend({ tags: z.array(z.string()).optional(), + verifiedOnly: z.boolean().optional(), }); diff --git a/apps/scan/src/services/db/resources/accepts.ts b/apps/scan/src/services/db/resources/accepts.ts index 9a67daaa2..d2ee6611e 100644 --- a/apps/scan/src/services/db/resources/accepts.ts +++ b/apps/scan/src/services/db/resources/accepts.ts @@ -8,10 +8,11 @@ import type { AcceptsNetwork, ResourceOrigin } from '@x402scan/scan-db'; interface GetAcceptsAddressesInput { chain?: Chain; tags?: string[]; + verified?: boolean; } export const getAcceptsAddresses = async (input: GetAcceptsAddressesInput) => { - const { chain, tags } = input; + const { chain, tags, verified } = input; const accepts = await scanDb.accepts.findMany({ include: { resourceRel: { @@ -22,6 +23,7 @@ export const getAcceptsAddresses = async (input: GetAcceptsAddressesInput) => { }, where: { network: chain as AcceptsNetwork, + ...(verified !== undefined ? { verified } : {}), ...(tags ? { resourceRel: { @@ -36,25 +38,39 @@ export const getAcceptsAddresses = async (input: GetAcceptsAddressesInput) => { }, }); - return accepts + const addressToOrigins: Record = {}; + const originVerification = new Map(); + + accepts .filter(accept => mixedAddressSchema.safeParse(accept.payTo).success) - .reduce( - (acc, accept) => { - if (!accept.payTo) { - return acc; - } - if (acc[accept.payTo]) { - const existingOrigin = acc[accept.payTo]!.find( - origin => origin.id === accept.resourceRel.origin.id - ); - if (!existingOrigin) { - acc[accept.payTo]!.push(accept.resourceRel.origin); - } - } else { - acc[accept.payTo] = [accept.resourceRel.origin]; + .forEach(accept => { + if (!accept.payTo) { + return; + } + + // Build address to origins map + if (addressToOrigins[accept.payTo]) { + const existingOrigin = addressToOrigins[accept.payTo]!.find( + origin => origin.id === accept.resourceRel.origin.id + ); + if (!existingOrigin) { + addressToOrigins[accept.payTo]!.push(accept.resourceRel.origin); } - return acc; - }, - {} as Record - ); + } else { + addressToOrigins[accept.payTo] = [accept.resourceRel.origin]; + } + + // Track verification status for each origin + const originId = accept.resourceRel.origin.id; + if (accept.verified) { + originVerification.set(originId, true); + } else if (!originVerification.has(originId)) { + originVerification.set(originId, false); + } + }); + + return { + addressToOrigins, + originVerification, + }; }; diff --git a/apps/scan/src/services/db/resources/origin.ts b/apps/scan/src/services/db/resources/origin.ts index 8fbf737b1..75f1ef37a 100644 --- a/apps/scan/src/services/db/resources/origin.ts +++ b/apps/scan/src/services/db/resources/origin.ts @@ -4,6 +4,7 @@ import { scanDb } from '@x402scan/scan-db'; import { parseX402Response } from '@/lib/x402'; import { mixedAddressSchema, optionalChainSchema } from '@/lib/schemas'; +import { createCachedArrayQuery, createStandardCacheKey } from '@/lib/cache'; import type { Prisma } from '@x402scan/scan-db'; @@ -81,25 +82,64 @@ export const listOriginsSchema = z.object({ address: mixedAddressSchema.optional(), }); -export const listOrigins = async (input: z.infer) => { +const listOriginsUncached = async ( + input: z.infer +) => { const { chain, address } = input; + + // Build where clause for accepts + const acceptsWhere = { + ...(address ? { payTo: address } : {}), + ...(chain ? { network: chain } : {}), + }; + const origins = await scanDb.resourceOrigin.findMany({ where: { resources: { some: { accepts: { - some: { - ...(address ? { payTo: address } : {}), - ...(chain ? { network: chain } : {}), + some: acceptsWhere, + }, + }, + }, + }, + include: { + resources: { + where: { + accepts: { + some: acceptsWhere, + }, + }, + select: { + id: true, + accepts: { + where: acceptsWhere, + select: { + verified: true, }, }, }, }, }, }); - return origins; + + // Map to include hasVerifiedAccept field + return origins.map(origin => ({ + ...origin, + hasVerifiedAccept: origin.resources.some(resource => + resource.accepts.some(accept => accept.verified) + ), + })); }; +export const listOrigins = createCachedArrayQuery({ + queryFn: listOriginsUncached, + cacheKeyPrefix: 'origins-list', + createCacheKey: input => createStandardCacheKey(input), + dateFields: [], + tags: ['origins'], +}); + export const listOriginsWithResourcesSchema = z.object({ chain: optionalChainSchema, address: mixedAddressSchema.optional(), diff --git a/apps/scan/src/services/transfers/sellers/list-mv.ts b/apps/scan/src/services/transfers/sellers/list-mv.ts index fa34f1228..5ee879221 100644 --- a/apps/scan/src/services/transfers/sellers/list-mv.ts +++ b/apps/scan/src/services/transfers/sellers/list-mv.ts @@ -11,6 +11,7 @@ import { } from '@/lib/cache'; import { queryRaw } from '@/services/transfers/client'; import { getMaterializedViewSuffix } from '@/lib/time-range'; +import { getAcceptsAddresses } from '@/services/db/resources/accepts'; import type { paginatedQuerySchema } from '@/lib/pagination'; @@ -26,6 +27,8 @@ export type SellerSortId = (typeof SELLERS_SORT_IDS)[number]; export const listTopSellersMVInputSchema = baseListQuerySchema({ sortIds: SELLERS_SORT_IDS, defaultSortId: 'tx_count', +}).extend({ + verifiedOnly: z.boolean().optional(), }); // Exported for use in listBazaarOrigins to avoid double-caching @@ -38,6 +41,25 @@ export const listTopSellersMVUncached = async ( const mvTimeframe = getMaterializedViewSuffix(timeframe); const tableName = `recipient_stats_aggregated_${mvTimeframe}`; + // When verifiedOnly is true, get only verified addresses + let verifiedAddresses: string[] | undefined; + if (input.verifiedOnly) { + const { addressToOrigins } = await getAcceptsAddresses({ + chain: input.chain, + verified: true, + }); + verifiedAddresses = Object.keys(addressToOrigins); + + // If no verified addresses exist, return empty result + if (verifiedAddresses.length === 0) { + return toPaginatedResponse({ + items: [], + total_count: 0, + ...pagination, + }); + } + } + // Build WHERE clause for materialized view const conditions: Prisma.Sql[] = [Prisma.sql`WHERE 1=1`]; @@ -51,7 +73,10 @@ export const listTopSellersMVUncached = async ( conditions.push(Prisma.sql`AND chain = ${input.chain}`); } - if (input.recipients?.include && input.recipients.include.length > 0) { + // Use verified addresses if verifiedOnly is true, otherwise use provided recipients + if (verifiedAddresses && verifiedAddresses.length > 0) { + conditions.push(Prisma.sql`AND recipient = ANY(${verifiedAddresses})`); + } else if (input.recipients?.include && input.recipients.include.length > 0) { conditions.push( Prisma.sql`AND recipient = ANY(${input.recipients.include})` ); diff --git a/apps/scan/src/services/transfers/stats/bucketed-mv.ts b/apps/scan/src/services/transfers/stats/bucketed-mv.ts index b94500e49..5456ba01c 100644 --- a/apps/scan/src/services/transfers/stats/bucketed-mv.ts +++ b/apps/scan/src/services/transfers/stats/bucketed-mv.ts @@ -5,8 +5,12 @@ import { baseBucketedQuerySchema } from '../schemas'; import { createCachedArrayQuery, createStandardCacheKey } from '@/lib/cache'; import { queryRaw } from '@/services/transfers/client'; import { getMaterializedViewSuffix } from '@/lib/time-range'; +import { getAcceptsAddresses } from '@/services/db/resources/accepts'; +import { mixedAddressSchema } from '@/lib/schemas'; -export const bucketedStatisticsMVInputSchema = baseBucketedQuerySchema; +export const bucketedStatisticsMVInputSchema = baseBucketedQuerySchema.extend({ + verifiedOnly: z.boolean().optional(), +}); const bucketedResultSchema = z.array( z.object({ @@ -21,7 +25,28 @@ const bucketedResultSchema = z.array( const getBucketedStatisticsMVUncached = async ( input: z.infer ) => { - const { timeframe, recipients } = input; + // When verifiedOnly is true, get only verified addresses + let modifiedInput = input; + if (input.verifiedOnly) { + const { addressToOrigins } = await getAcceptsAddresses({ + chain: input.chain, + verified: true, + }); + const verifiedAddresses = Object.keys(addressToOrigins).map(addr => + mixedAddressSchema.parse(addr) + ); + + // Add verified addresses to recipients.include + modifiedInput = { + ...input, + recipients: { + ...input.recipients, + include: verifiedAddresses, + }, + }; + } + + const { timeframe, recipients } = modifiedInput; const mvTimeframe = getMaterializedViewSuffix(timeframe); @@ -37,17 +62,17 @@ const getBucketedStatisticsMVUncached = async ( // Build WHERE clause for materialized view const conditions: Prisma.Sql[] = [Prisma.sql`WHERE 1=1`]; - if (input.facilitatorIds && input.facilitatorIds.length > 0) { + if (modifiedInput.facilitatorIds && modifiedInput.facilitatorIds.length > 0) { // Note: bucketed views may not have facilitator_ids column if (!hasRecipientFilter) { conditions.push( - Prisma.sql`AND facilitator_id = ANY(${input.facilitatorIds})` + Prisma.sql`AND facilitator_id = ANY(${modifiedInput.facilitatorIds})` ); } } - if (input.chain) { - conditions.push(Prisma.sql`AND chain = ${input.chain}`); + if (modifiedInput.chain) { + conditions.push(Prisma.sql`AND chain = ${modifiedInput.chain}`); } if (recipients?.include && recipients.include.length > 0) { diff --git a/apps/scan/src/services/transfers/stats/overall-mv.ts b/apps/scan/src/services/transfers/stats/overall-mv.ts index 7c16a9f9e..a0f81421b 100644 --- a/apps/scan/src/services/transfers/stats/overall-mv.ts +++ b/apps/scan/src/services/transfers/stats/overall-mv.ts @@ -5,13 +5,38 @@ import { baseQuerySchema } from '../schemas'; import { createCachedQuery, createStandardCacheKey } from '@/lib/cache'; import { queryRaw } from '@/services/transfers/client'; import { getMaterializedViewSuffix } from '@/lib/time-range'; +import { getAcceptsAddresses } from '@/services/db/resources/accepts'; +import { mixedAddressSchema } from '@/lib/schemas'; -export const overallStatisticsMVInputSchema = baseQuerySchema; +export const overallStatisticsMVInputSchema = baseQuerySchema.extend({ + verifiedOnly: z.boolean().optional(), +}); const getOverallStatisticsMVUncached = async ( input: z.infer ) => { - const { timeframe, recipients } = input; + // When verifiedOnly is true, get only verified addresses + let modifiedInput = input; + if (input.verifiedOnly) { + const { addressToOrigins } = await getAcceptsAddresses({ + chain: input.chain, + verified: true, + }); + const verifiedAddresses = Object.keys(addressToOrigins).map(addr => + mixedAddressSchema.parse(addr) + ); + + // Add verified addresses to recipients.include + modifiedInput = { + ...input, + recipients: { + ...input.recipients, + include: verifiedAddresses, + }, + }; + } + + const { timeframe, recipients } = modifiedInput; const mvTimeframe = getMaterializedViewSuffix(timeframe); // Use recipient-specific materialized view when filtering by recipients @@ -26,21 +51,21 @@ const getOverallStatisticsMVUncached = async ( // Build WHERE clause for materialized view const conditions: Prisma.Sql[] = [Prisma.sql`WHERE 1=1`]; - if (input.facilitatorIds && input.facilitatorIds.length > 0) { + if (modifiedInput.facilitatorIds && modifiedInput.facilitatorIds.length > 0) { // Recipient stats tables use facilitator_ids array, aggregated stats use facilitator_id if (hasRecipientFilter) { conditions.push( - Prisma.sql`AND ${input.facilitatorIds}::text[] && facilitator_ids` + Prisma.sql`AND ${modifiedInput.facilitatorIds}::text[] && facilitator_ids` ); } else { conditions.push( - Prisma.sql`AND facilitator_id = ANY(${input.facilitatorIds})` + Prisma.sql`AND facilitator_id = ANY(${modifiedInput.facilitatorIds})` ); } } - if (input.chain) { - conditions.push(Prisma.sql`AND chain = ${input.chain}`); + if (modifiedInput.chain) { + conditions.push(Prisma.sql`AND chain = ${modifiedInput.chain}`); } if (recipients?.include && recipients.include.length > 0) { diff --git a/apps/scan/src/services/transfers/transfers/list.ts b/apps/scan/src/services/transfers/transfers/list.ts index c008231bb..ee8c92824 100644 --- a/apps/scan/src/services/transfers/transfers/list.ts +++ b/apps/scan/src/services/transfers/transfers/list.ts @@ -1,4 +1,4 @@ -import type z from 'zod'; +import z from 'zod'; import { toPeekAheadResponse, @@ -13,6 +13,8 @@ import { transfersDb } from '@x402scan/transfers-db'; import type { MixedAddress } from '@/types/address'; import type { Chain } from '@/types/chain'; import { transfersWhereObject } from '../query-utils'; +import { getAcceptsAddresses } from '@/services/db/resources/accepts'; +import { mixedAddressSchema } from '@/lib/schemas'; const TRANSFERS_SORT_IDS = ['block_timestamp', 'amount'] as const; export type TransfersSortId = (typeof TRANSFERS_SORT_IDS)[number]; @@ -20,6 +22,8 @@ export type TransfersSortId = (typeof TRANSFERS_SORT_IDS)[number]; export const listFacilitatorTransfersInputSchema = baseListQuerySchema({ sortIds: TRANSFERS_SORT_IDS, defaultSortId: 'block_timestamp', +}).extend({ + verifiedOnly: z.boolean().optional(), }); const listFacilitatorTransfersUncached = async ( @@ -29,7 +33,36 @@ const listFacilitatorTransfersUncached = async ( const { sorting } = input; const { page_size, page } = pagination; - const where = transfersWhereObject(input); + // When verifiedOnly is true, get only verified addresses + let modifiedInput = input; + if (input.verifiedOnly) { + const { addressToOrigins } = await getAcceptsAddresses({ + chain: input.chain, + verified: true, + }); + const verifiedAddresses = Object.keys(addressToOrigins).map(addr => + mixedAddressSchema.parse(addr) + ); + + // If no verified addresses exist, return empty result + if (verifiedAddresses.length === 0) { + return toPeekAheadResponse({ + items: [], + ...pagination, + }); + } + + // Add verified addresses to recipients.include + modifiedInput = { + ...input, + recipients: { + ...input.recipients, + include: verifiedAddresses, + }, + }; + } + + const where = transfersWhereObject(modifiedInput); const transfers = await transfersDb.transferEvent.findMany({ where, orderBy: { diff --git a/packages/internal/databases/scan/prisma/migrations/20260116005442_add_accepts_payto_indexes/migration.sql b/packages/internal/databases/scan/prisma/migrations/20260116005442_add_accepts_payto_indexes/migration.sql new file mode 100644 index 000000000..bc173fe73 --- /dev/null +++ b/packages/internal/databases/scan/prisma/migrations/20260116005442_add_accepts_payto_indexes/migration.sql @@ -0,0 +1,8 @@ +-- CreateIndex +CREATE INDEX "Accepts_payTo_idx" ON "Accepts"("payTo"); + +-- CreateIndex +CREATE INDEX "Accepts_payTo_network_idx" ON "Accepts"("payTo", "network"); + +-- CreateIndex +CREATE INDEX "Accepts_payTo_verified_idx" ON "Accepts"("payTo", "verified"); diff --git a/packages/internal/databases/scan/prisma/schema.prisma b/packages/internal/databases/scan/prisma/schema.prisma index b05b2383f..0f130c442 100644 --- a/packages/internal/databases/scan/prisma/schema.prisma +++ b/packages/internal/databases/scan/prisma/schema.prisma @@ -142,6 +142,9 @@ model Accepts { @@unique([resourceId, scheme, network]) @@index([verified]) @@index([resourceId, verified]) + @@index([payTo]) + @@index([payTo, network]) + @@index([payTo, verified]) } model News { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcf8bcff9..3c2ab2cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,6 @@ settings: excludeLinksFromLockfile: false catalogs: - clickhouse: - '@clickhouse/client': - specifier: ^1.12.1 - version: 1.12.1 coinbase: '@coinbase/cdp-core': specifier: ^0.0.74 @@ -47,9 +43,6 @@ catalogs: superjson: specifier: ^2.2.6 version: 2.2.6 - tsup: - specifier: ^8.5.1 - version: 8.5.1 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -66,24 +59,7 @@ catalogs: dotenv: specifier: ^17.2.3 version: 17.2.3 - express: - '@types/express': - specifier: ^5.0.1 - version: 5.0.5 - express: - specifier: ^5.2.1 - version: 5.2.1 - hono: - '@hono/node-server': - specifier: ^1.13.8 - version: 1.19.6 - hono: - specifier: ^4.7.1 - version: 4.10.6 next: - eslint-config-next: - specifier: 16.1.1 - version: 16.1.1 next: specifier: 16.1.1 version: 16.1.1 @@ -91,15 +67,9 @@ catalogs: specifier: ^0.4.6 version: 0.4.6 prisma: - '@prisma/adapter-neon': - specifier: ^7.2.0 - version: 7.2.0 '@prisma/client': specifier: ^7.2.0 version: 7.2.0 - prisma: - specifier: ^7.2.0 - version: 7.2.0 react: '@types/react': specifier: 19.2.2 @@ -157,15 +127,9 @@ catalogs: x402: specifier: ^0.7.1 version: 0.7.3 - x402-express: - specifier: ^0.7.1 - version: 0.7.1 x402-fetch: specifier: ^0.7.1 version: 0.7.3 - x402-hono: - specifier: ^0.7.1 - version: 0.7.1 x402-next: specifier: ^0.7.1 version: 0.7.1 @@ -12338,7 +12302,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -12354,7 +12318,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -12423,7 +12387,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@gemini-wallet/core@0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))': + '@gemini-wallet/core@0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': dependencies: '@metamask/rpc-errors': 7.0.2 eventemitter3: 5.0.1 @@ -12513,7 +12477,7 @@ snapshots: '@antfu/install-pkg': 1.1.0 '@antfu/utils': 9.3.0 '@iconify/types': 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 globals: 15.15.0 kolorist: 1.8.0 local-pkg: 1.1.2 @@ -12926,7 +12890,7 @@ snapshots: '@scure/base': 1.2.6 '@types/debug': 4.1.12 '@types/lodash': 4.17.20 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 lodash: 4.17.21 pony-cause: 2.1.11 semver: 7.7.3 @@ -12938,7 +12902,7 @@ snapshots: dependencies: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 semver: 7.7.3 superstruct: 1.0.4 transitivePeerDependencies: @@ -12951,7 +12915,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 pony-cause: 2.1.11 semver: 7.7.3 uuid: 9.0.1 @@ -12965,7 +12929,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 pony-cause: 2.1.11 semver: 7.7.3 uuid: 9.0.1 @@ -13362,7 +13326,7 @@ snapshots: '@opentelemetry/api-logs': 0.56.0 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2(supports-color@10.2.2) + require-in-the-middle: 7.5.2 semver: 7.7.3 shimmer: 1.2.1 transitivePeerDependencies: @@ -13374,7 +13338,7 @@ snapshots: '@opentelemetry/api-logs': 0.57.2 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2(supports-color@10.2.2) + require-in-the-middle: 7.5.2 semver: 7.7.3 shimmer: 1.2.1 transitivePeerDependencies: @@ -17876,7 +17840,7 @@ snapshots: dependencies: '@base-org/account': 2.4.0(@types/react@19.2.2)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.3.5) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(zod@4.3.5) - '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) @@ -17930,7 +17894,7 @@ snapshots: dependencies: '@base-org/account': 2.4.0(@types/react@19.2.2)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -17984,7 +17948,7 @@ snapshots: dependencies: '@base-org/account': 2.4.0(@types/react@19.2.2)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@gemini-wallet/core': 0.3.1(viem@2.44.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -20352,12 +20316,20 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.3.7(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 10.2.2 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 @@ -20592,7 +20564,7 @@ snapshots: engine.io-client@6.6.3(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7(supports-color@10.2.2) + debug: 4.3.7 engine.io-parser: 5.2.3 ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) xmlhttprequest-ssl: 2.1.2 @@ -21105,7 +21077,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -21970,7 +21942,7 @@ snapshots: dependencies: '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -22894,7 +22866,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -24148,6 +24120,14 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + require-in-the-middle@7.5.2(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -24544,14 +24524,21 @@ snapshots: socket.io-client@4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7(supports-color@10.2.2) + debug: 4.3.7 engine.io-client: 6.6.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - socket.io-parser: 4.2.4(supports-color@10.2.2) + socket.io-parser: 4.2.4 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + socket.io-parser@4.2.4(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 @@ -25507,7 +25494,7 @@ snapshots: vite-node@3.2.4(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.12(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.1) @@ -25596,7 +25583,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 expect-type: 1.2.2 magic-string: 0.30.21 pathe: 2.0.3