diff --git a/apps/scan/package.json b/apps/scan/package.json index 9a1ccd720..d9d43d8db 100644 --- a/apps/scan/package.json +++ b/apps/scan/package.json @@ -15,6 +15,8 @@ "test": "vitest", "test:run": "vitest run", "sync:ecosystem": "tsx src/scripts/sync-ecosystem.ts", + "view:facilitators": "tsx src/scripts/view-facilitators.ts", + "test:sync": "tsx src/scripts/test-sync.ts", "knip": "pnpm -w knip --workspace ./apps/scan", "format": "pnpm -w format-dir ./apps/scan", "check:format": "pnpm -w check:format-dir ./apps/scan" @@ -68,6 +70,8 @@ "@vercel/speed-insights": "^1.2.0", "@wallet-standard/base": "^1.1.0", "@wallet-standard/react": "^1.0.1", + "@x402/core": "^2.2.0", + "@x402/extensions": "^2.2.0", "@x402scan/analytics-db": "workspace:*", "@x402scan/scan-db": "workspace:*", "@x402scan/transfers-db": "workspace:*", diff --git a/apps/scan/src/app/api/resources/sync/route.ts b/apps/scan/src/app/api/resources/sync/route.ts index e4d2d9dae..2a1f3e3ec 100644 --- a/apps/scan/src/app/api/resources/sync/route.ts +++ b/apps/scan/src/app/api/resources/sync/route.ts @@ -1,206 +1,508 @@ import { NextResponse } from 'next/server'; -import { scrapeOriginData } from '@/services/scraper'; -import { upsertOrigin } from '@/services/db/resources/origin'; -import { upsertResource } from '@/services/db/resources/resource'; +import { HTTPFacilitatorClient } from '@x402/core/http'; +import { withBazaar } from '@x402/extensions/bazaar'; +import { Prisma, scanDb } from '@x402scan/scan-db'; +import { discoverableFacilitators } from 'facilitators'; import { checkCronSecret } from '@/lib/cron'; import { getOriginFromUrl } from '@/lib/url'; +import { scrapeOriginData } from '@/services/scraper'; +import { upsertResourceSchema } from '@/services/db/resources/resource'; +import { SUPPORTED_CHAINS } from '@/types/chain'; +import type { z } from 'zod'; import type { AcceptsNetwork } from '@x402scan/scan-db/types'; -import type z from 'zod'; -import type { upsertResourceSchema } from '@/services/db/resources/resource'; +import type { SupportedChain } from '@/types/chain'; import type { NextRequest } from 'next/server'; -import { - discoverableFacilitators, - listAllFacilitatorResources, -} from 'facilitators'; +import type { FacilitatorConfig } from 'x402/types'; + +// ============================================================================ +// Constants +// ============================================================================ + +const SCRAPE_CONCURRENCY = 10; +const DB_BATCH_SIZE = 100; +const PAGE_LIMIT = 100; + +// ============================================================================ +// Types +// ============================================================================ + +interface ScrapedOrigin { + origin: string; + title?: string; + description?: string; + favicon?: string; + ogImages: { + url: string; + height?: number; + width?: number; + title?: string; + description?: string; + }[]; +} + +interface ValidatedResource { + parsed: z.output; + originUrl: string; +} + +// Resource from facilitator discovery API +interface FacilitatorResource { + resource: string; + type: string; + x402Version: number; + lastUpdated: string; + accepts: { + scheme: string; + network: string; + payTo: string; + description: string; + maxAmountRequired: string; + mimeType: string; + maxTimeoutSeconds: number; + asset: string; + outputSchema?: unknown; + extra?: Record; + }[]; +} + +// ============================================================================ +// Facilitator Resource Fetching +// ============================================================================ + +/** + * Create a bazaar-enabled facilitator client + */ +function createBazaarClient(facilitator: FacilitatorConfig) { + return withBazaar(new HTTPFacilitatorClient({ url: facilitator.url })); +} + +/** + * Fetch all resources from a single facilitator using the Bazaar extension + */ +async function fetchFacilitatorResources( + facilitator: FacilitatorConfig +): Promise { + const client = createBazaarClient(facilitator); + const allResources: FacilitatorResource[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const response = await client.extensions.discovery.listResources({ + type: 'http', + limit: PAGE_LIMIT, + offset, + }); + + // The response may have different shapes depending on facilitator + // Handle both { resources } and { items } formats + const items = + (response as unknown as { items?: FacilitatorResource[] }).items ?? + (response.resources as unknown as FacilitatorResource[]); + + if (!items || items.length === 0) { + hasMore = false; + break; + } + + allResources.push(...items); + + // Check pagination + const total = response.total ?? 0; + if (offset + PAGE_LIMIT >= total || items.length < PAGE_LIMIT) { + hasMore = false; + } else { + offset += PAGE_LIMIT; + } + } + + return allResources; +} + +/** + * Fetch resources from all facilitators + */ +async function fetchAllResources(skip: string[] = []): Promise<{ + resources: FacilitatorResource[]; + errors: { facilitator: string; error: string }[]; + skippedFacilitators: string[]; +}> { + const resources: FacilitatorResource[] = []; + const errors: { facilitator: string; error: string }[] = []; + const skippedFacilitators: string[] = []; + + for (const facilitator of discoverableFacilitators) { + // Check if this facilitator should be skipped + if ( + skip.some(s => facilitator.url.toLowerCase().includes(s.toLowerCase())) + ) { + skippedFacilitators.push(facilitator.url); + continue; + } + + try { + console.log(`[sync] Fetching from ${facilitator.url}`); + const facilitatorResources = await fetchFacilitatorResources(facilitator); + console.log( + `[sync] Fetched ${facilitatorResources.length} from ${facilitator.url}` + ); + resources.push(...facilitatorResources); + } catch (error) { + console.error(`[sync] Failed to fetch from ${facilitator.url}`, { + error: error instanceof Error ? error.message : 'Unknown error', + }); + errors.push({ + facilitator: facilitator.url, + error: error instanceof Error ? error.message : 'Unknown', + }); + } + } + + return { resources, errors, skippedFacilitators }; +} + +// ============================================================================ +// Origin Scraping +// ============================================================================ + +async function scrapeOriginsInBatches( + origins: string[], + concurrency: number +): Promise { + const results: ScrapedOrigin[] = []; + + for (let i = 0; i < origins.length; i += concurrency) { + const batch = origins.slice(i, i + concurrency); + const batchResults = await Promise.allSettled( + batch.map(async (origin): Promise => { + const { + og, + metadata, + origin: scrapedOrigin, + } = await scrapeOriginData(origin); + return { + origin, + title: metadata?.title ?? og?.ogTitle, + description: metadata?.description ?? og?.ogDescription, + favicon: og?.favicon + ? og.favicon.startsWith('/') + ? scrapedOrigin.replace(/\/$/, '') + og.favicon + : og.favicon + : undefined, + ogImages: + og?.ogImage?.map(image => ({ + url: image.url, + height: image.height, + width: image.width, + title: og.ogTitle, + description: og.ogDescription, + })) ?? [], + }; + }) + ); + + for (const result of batchResults) { + if (result.status === 'fulfilled') { + results.push(result.value); + } + } + } + + return results; +} + +// ============================================================================ +// Database Operations +// ============================================================================ + +async function batchUpsertOrigins(origins: ScrapedOrigin[]) { + if (origins.length === 0) return { originsUpserted: 0, ogImagesUpserted: 0 }; + + let totalOriginsUpserted = 0; + let totalOgImagesUpserted = 0; + + for (let i = 0; i < origins.length; i += DB_BATCH_SIZE) { + const batch = origins.slice(i, i + DB_BATCH_SIZE); + + const originValues = batch.map( + o => + Prisma.sql`(gen_random_uuid(), ${o.origin}, ${o.title ?? null}, ${o.description ?? null}, ${o.favicon ?? null}, NOW(), NOW())` + ); + + const upsertedOrigins = await scanDb.$executeRaw` + INSERT INTO "public"."ResourceOrigin" (id, origin, title, description, favicon, "createdAt", "updatedAt") + VALUES ${Prisma.join(originValues)} + ON CONFLICT (origin) DO UPDATE SET + title = COALESCE(EXCLUDED.title, "ResourceOrigin".title), + description = COALESCE(EXCLUDED.description, "ResourceOrigin".description), + favicon = COALESCE(EXCLUDED.favicon, "ResourceOrigin".favicon), + "updatedAt" = NOW() + `; + totalOriginsUpserted += upsertedOrigins; + + const ogImagesWithOrigins = batch.flatMap(o => + o.ogImages.map(img => ({ ...img, originUrl: o.origin })) + ); + + if (ogImagesWithOrigins.length > 0) { + const ogImageValues = ogImagesWithOrigins.map( + img => + Prisma.sql`(gen_random_uuid(), ${img.originUrl}, ${img.url}, ${img.height ?? null}, ${img.width ?? null}, ${img.title ?? null}, ${img.description ?? null})` + ); + + const upsertedImages = await scanDb.$executeRaw` + INSERT INTO "public"."OgImage" (id, "originId", url, height, width, title, description) + SELECT v.id, ro.id as "originId", v.url, v.height, v.width, v.title, v.description + FROM (VALUES ${Prisma.join(ogImageValues)}) AS v(id, origin_url, url, height, width, title, description) + JOIN "public"."ResourceOrigin" ro ON ro.origin = v.origin_url + ON CONFLICT (url) DO UPDATE SET + height = COALESCE(EXCLUDED.height, "OgImage".height), + width = COALESCE(EXCLUDED.width, "OgImage".width), + title = COALESCE(EXCLUDED.title, "OgImage".title), + description = COALESCE(EXCLUDED.description, "OgImage".description) + `; + totalOgImagesUpserted += upsertedImages; + } + } + + return { + originsUpserted: totalOriginsUpserted, + ogImagesUpserted: totalOgImagesUpserted, + }; +} + +function validateResources(resources: FacilitatorResource[]): { + validated: ValidatedResource[]; + skipped: number; + skipReasons: Record; +} { + const validated: ValidatedResource[] = []; + let skipped = 0; + const skipReasons: Record = {}; + + const addSkipReason = (reason: string) => { + skipped++; + skipReasons[reason] = (skipReasons[reason] ?? 0) + 1; + }; + + for (const resource of resources) { + const parsed = upsertResourceSchema.safeParse({ + ...resource, + accepts: resource.accepts.map(accept => ({ + ...accept, + network: accept.network.replace('-', '_') as AcceptsNetwork, + })), + }); + + if (!parsed.success) { + const errorPath = parsed.error.issues[0]?.path.join('.') ?? 'unknown'; + addSkipReason(`validation_failed:${errorPath}`); + continue; + } + + const supportedAccepts = parsed.data.accepts.filter(accept => + SUPPORTED_CHAINS.includes(accept.network as SupportedChain) + ); + + if (supportedAccepts.length === 0) { + const networks = parsed.data.accepts.map(a => a.network).join(','); + addSkipReason(`unsupported_network:${networks}`); + continue; + } + + try { + const originUrl = getOriginFromUrl(parsed.data.resource); + validated.push({ + parsed: { ...parsed.data, accepts: supportedAccepts }, + originUrl, + }); + } catch { + addSkipReason('invalid_url'); + } + } + + return { validated, skipped, skipReasons }; +} + +async function batchUpsertResources(resources: FacilitatorResource[]) { + if (resources.length === 0) { + return { + resourcesUpserted: 0, + acceptsUpserted: 0, + skipped: 0, + skipReasons: {}, + }; + } + + const { validated, skipped, skipReasons } = validateResources(resources); + let totalResourcesUpserted = 0; + let totalAcceptsUpserted = 0; + + for (let i = 0; i < validated.length; i += DB_BATCH_SIZE) { + const batch = validated.slice(i, i + DB_BATCH_SIZE); + + const resourceValues = batch.map( + r => + Prisma.sql`(gen_random_uuid(), ${r.parsed.resource}, ${r.parsed.type}::"public"."ResourceType", ${r.parsed.x402Version}, ${r.parsed.lastUpdated}, ${r.parsed.metadata ? JSON.stringify(r.parsed.metadata) : null}::jsonb, ${r.originUrl})` + ); + + const upsertedResources = await scanDb.$executeRaw` + INSERT INTO "public"."Resources" (id, resource, type, "x402Version", "lastUpdated", metadata, "originId") + SELECT v.id, v.resource, v.type, v.x402_version, v.last_updated, v.metadata, ro.id as "originId" + FROM (VALUES ${Prisma.join(resourceValues)}) AS v(id, resource, type, x402_version, last_updated, metadata, origin_url) + JOIN "public"."ResourceOrigin" ro ON ro.origin = v.origin_url + ON CONFLICT (resource) DO UPDATE SET + type = EXCLUDED.type, + "x402Version" = EXCLUDED."x402Version", + "lastUpdated" = EXCLUDED."lastUpdated", + metadata = COALESCE(EXCLUDED.metadata, "Resources".metadata) + `; + totalResourcesUpserted += upsertedResources; + + const acceptsWithResources = batch.flatMap(r => + r.parsed.accepts.map(accept => ({ + ...accept, + resourceUrl: r.parsed.resource, + })) + ); + + if (acceptsWithResources.length > 0) { + const acceptValues = acceptsWithResources.map( + a => + Prisma.sql`(gen_random_uuid(), ${a.resourceUrl}, ${a.scheme}::"public"."AcceptsScheme", ${a.description}, ${a.network}::"public"."AcceptsNetwork", ${BigInt(a.maxAmountRequired)}, ${a.resourceUrl}, ${a.mimeType}, ${a.payTo}, ${a.maxTimeoutSeconds}, ${a.asset}, ${a.outputSchema ? JSON.stringify(a.outputSchema) : null}::jsonb, ${a.extra ? JSON.stringify(a.extra) : null}::jsonb)` + ); + + const upsertedAccepts = await scanDb.$executeRaw` + INSERT INTO "public"."Accepts" (id, "resourceId", scheme, description, network, "maxAmountRequired", resource, "mimeType", "payTo", "maxTimeoutSeconds", asset, "outputSchema", extra) + SELECT v.id, r.id as "resourceId", v.scheme, v.description, v.network, v.max_amount_required, v.resource_url, v.mime_type, v.pay_to, v.max_timeout_seconds, v.asset, v.output_schema, v.extra + FROM (VALUES ${Prisma.join(acceptValues)}) AS v(id, resource_url, scheme, description, network, max_amount_required, resource_url_2, mime_type, pay_to, max_timeout_seconds, asset, output_schema, extra) + JOIN "public"."Resources" r ON r.resource = v.resource_url + ON CONFLICT ("resourceId", scheme, network) DO UPDATE SET + description = EXCLUDED.description, + "maxAmountRequired" = EXCLUDED."maxAmountRequired", + "mimeType" = EXCLUDED."mimeType", + "payTo" = EXCLUDED."payTo", + "maxTimeoutSeconds" = EXCLUDED."maxTimeoutSeconds", + asset = EXCLUDED.asset, + "outputSchema" = COALESCE(EXCLUDED."outputSchema", "Accepts"."outputSchema"), + extra = COALESCE(EXCLUDED.extra, "Accepts".extra) + `; + totalAcceptsUpserted += upsertedAccepts; + } + } + + return { + resourcesUpserted: totalResourcesUpserted, + acceptsUpserted: totalAcceptsUpserted, + skipped, + skipReasons, + }; +} + +// ============================================================================ +// Route Handler +// ============================================================================ + +export const maxDuration = 300; export const GET = async (request: NextRequest) => { const cronCheck = checkCronSecret(request); - if (cronCheck) { - return cronCheck; - } + if (cronCheck) return cronCheck; + + const startTime = Date.now(); + const skipParam = request.nextUrl.searchParams.get('skip'); + const skip = skipParam ? skipParam.split(',').map(s => s.trim()) : []; try { - // Step 1: Fetch facilitator resources - console.log('Fetching facilitator resources'); - const resources = ( - await Promise.all( - discoverableFacilitators.map(facilitator => - listAllFacilitatorResources(facilitator).catch(error => { - console.error('Failed to fetch facilitator resources', { - facilitator: facilitator.url, - error: error instanceof Error ? error.message : 'Unknown error', - }); - return []; - }) - ) - ) - ).flat(); - console.log('Successfully fetched facilitator resources', { - totalResources: resources.length, + // Step 1: Fetch resources from facilitators + console.log('[sync] Starting resource fetch', { + skip: skip.length > 0 ? skip : 'none', + }); + const { + resources, + errors: fetchErrors, + skippedFacilitators, + } = await fetchAllResources(skip); + console.log('[sync] Fetched resources', { + count: resources.length, + errors: fetchErrors.length, }); if (resources.length === 0) { - console.warn('No resources found from facilitator'); - return NextResponse.json( - { - success: true as const, - message: 'No resources to sync', - resourcesProcessed: 0, - originsProcessed: 0, - }, - { status: 200 } - ); + return NextResponse.json({ + success: true, + message: 'No resources to sync', + skippedFacilitators, + facilitatorErrors: fetchErrors, + }); } - // Step 2: Extract unique origins - console.log('Extracting unique origins from resources'); - const origins = new Set(); + // Step 2: Extract and scrape unique origins + const originsSet = new Set(); for (const resource of resources) { try { - const origin = getOriginFromUrl(resource.resource); - origins.add(origin); - } catch (error) { - console.warn('Failed to extract origin from resource', { - resource: resource.resource, - error: error instanceof Error ? error.message : 'Unknown error', - }); + originsSet.add(getOriginFromUrl(resource.resource)); + } catch { + // Skip invalid URLs } } + const uniqueOrigins = Array.from(originsSet); - const uniqueOrigins = Array.from(origins); - - // Step 3: Process origins (scrape metadata and OG data) - console.log('Starting origin processing with metadata scraping'); - const originProcessingStart = Date.now(); - - const originResults = await Promise.allSettled( - uniqueOrigins.map(async origin => { - const originStart = Date.now(); - - try { - // Scrape OG and metadata in parallel - const { - og, - metadata, - origin: scrapedOrigin, - } = await scrapeOriginData(origin); - - // Prepare origin data - const originData = { - origin: origin, - title: metadata?.title ?? og?.ogTitle, - description: metadata?.description ?? og?.ogDescription, - favicon: - og?.favicon && - (og.favicon.startsWith('/') - ? scrapedOrigin.replace(/\/$/, '') + og.favicon - : og.favicon), - ogImages: - og?.ogImage?.map(image => ({ - url: image.url, - height: image.height, - width: image.width, - title: og.ogTitle, - description: og.ogDescription, - })) ?? [], - }; - - // Upsert origin to database - await upsertOrigin(originData); - - return { origin, success: true }; - } catch (error) { - console.error('Failed to process origin', { - origin, - durationMs: Date.now() - originStart, - }); - return { origin, success: false, error }; - } - }) + console.log('[sync] Scraping origins', { count: uniqueOrigins.length }); + const scrapedOrigins = await scrapeOriginsInBatches( + uniqueOrigins, + SCRAPE_CONCURRENCY ); - // Analyze origin processing results - const successfulOrigins = originResults.filter( - ( - result - ): result is PromiseFulfilledResult<{ - origin: string; - success: true; - }> => result.status === 'fulfilled' && result.value.success - ).length; - const failedOrigins = originResults.length - successfulOrigins; - - console.log('Completed origin processing', { - totalOrigins: uniqueOrigins.length, - successful: successfulOrigins, - failed: failedOrigins, - durationMs: Date.now() - originProcessingStart, + // Step 3: Upsert origins + const { originsUpserted, ogImagesUpserted } = + await batchUpsertOrigins(scrapedOrigins); + console.log('[sync] Upserted origins', { + originsUpserted, + ogImagesUpserted, }); - // Step 4: Process resources (upsert to database) - console.log('Starting resource processing'); - const resourceProcessingStart = Date.now(); - - const resourceResults = await Promise.allSettled( - resources.map(async facilitatorResource => { - try { - await upsertResource({ - ...facilitatorResource, - accepts: facilitatorResource.accepts.map(accept => ({ - ...accept, - network: accept.network.replace('-', '_') as AcceptsNetwork, - })) as z.input['accepts'], - }); - return { resource: facilitatorResource.resource, success: true }; - } catch (error) { - return { - resource: facilitatorResource.resource, - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }) - ); - - // Analyze resource processing results - const successfulResources = resourceResults.filter( - ( - result - ): result is PromiseFulfilledResult<{ - resource: string; - success: true; - }> => result.status === 'fulfilled' && result.value.success - ).length; - const failedResources = resourceResults.length - successfulResources; - - console.log('Completed resource processing', { - totalResources: resources.length, - successful: successfulResources, - failed: failedResources, - durationMs: Date.now() - resourceProcessingStart, + // Step 4: Upsert resources + const { resourcesUpserted, acceptsUpserted, skipped, skipReasons } = + await batchUpsertResources(resources); + console.log('[sync] Upserted resources', { + resourcesUpserted, + acceptsUpserted, + skipped, + skipReasons, }); - // Final summary - const totalDuration = Date.now() - resourceProcessingStart; - const result = { - success: true as const, - message: `Sync completed successfully`, - resourcesProcessed: successfulResources, - resourcesFailed: failedResources, - originsProcessed: successfulOrigins, - originsFailed: failedOrigins, + const totalDuration = Date.now() - startTime; + console.log('[sync] Completed', { durationMs: totalDuration }); + + return NextResponse.json({ + success: true, + message: 'Sync completed successfully', + skippedFacilitators, + facilitatorErrors: fetchErrors, + originsScraped: scrapedOrigins.length, + originsScrapesFailed: uniqueOrigins.length - scrapedOrigins.length, + originsUpserted, + ogImagesUpserted, + resourcesUpserted, + acceptsUpserted, + resourcesSkipped: skipped, + resourcesSkipReasons: skipReasons, durationMs: totalDuration, - }; - return NextResponse.json(result, { status: 200 }); + }); } catch (error) { + console.error('[sync] Failed', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }); return NextResponse.json( { - success: false as const, - message: 'Sync task failed with error', - error: error instanceof Error ? error.message : 'Unknown error', + success: false, + message: 'Sync failed', + error: error instanceof Error ? error.message : 'Unknown', }, { status: 500 } ); diff --git a/apps/scan/src/scripts/view-facilitators.ts b/apps/scan/src/scripts/view-facilitators.ts new file mode 100644 index 000000000..0b96f3906 --- /dev/null +++ b/apps/scan/src/scripts/view-facilitators.ts @@ -0,0 +1,475 @@ +/** + * Script to view discoverable facilitators and their resources locally. + * + * Usage: + * pnpm view:facilitators + * pnpm view:facilitators -- --list-only + * pnpm view:facilitators -- --facilitator=coinbase + * pnpm view:facilitators -- --verbose + * pnpm view:facilitators -- --skip=coinbase + * + * Environment variables (for authenticated CDP requests): + * CDP_API_KEY_ID - CDP API Key ID + * CDP_API_KEY_SECRET - CDP API Key Secret + * + * Automatically loads .env file from apps/scan/.env + */ + +import 'dotenv/config'; +import { generateJwt } from '@coinbase/cdp-sdk/auth'; +import { useFacilitator as facilitatorUtils } from 'x402/verify'; +import { discoverableFacilitators } from 'facilitators'; + +import type { + FacilitatorConfig, + ListDiscoveryResourcesRequest, + ListDiscoveryResourcesResponse, +} from 'x402/types'; + +// ============================================================================ +// Types +// ============================================================================ + +type FacilitatorResource = ListDiscoveryResourcesResponse['items'][number]; + +interface FetchOptions { + delayMs?: number; + onProgress?: (count: number, total: number) => void; +} + +// ============================================================================ +// CDP Authenticated Fetch +// ============================================================================ + +const CDP_HOST = 'api.cdp.coinbase.com'; +const CDP_DISCOVERY_PATH = '/platform/v2/x402/discovery/resources'; + +/** + * Check if CDP credentials are available + */ +function hasCdpCredentials(): boolean { + return !!(process.env.CDP_API_KEY_ID && process.env.CDP_API_KEY_SECRET); +} + +/** + * Fetch from CDP API with JWT authentication + */ +async function cdpAuthenticatedFetch( + path: string, + method: 'GET' | 'POST' = 'GET' +): Promise<{ data: T; rateLimitInfo?: RateLimitInfo }> { + const apiKeyId = process.env.CDP_API_KEY_ID; + const apiKeySecret = process.env.CDP_API_KEY_SECRET; + + if (!apiKeyId || !apiKeySecret) { + throw new Error('CDP credentials not found in environment'); + } + + // Generate JWT (path without query params for signing) + const [basePath] = path.split('?'); + const jwt = await generateJwt({ + apiKeyId, + apiKeySecret, + requestMethod: method, + requestHost: CDP_HOST, + requestPath: basePath!, + expiresIn: 120, + }); + + const url = `https://${CDP_HOST}${path}`; + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }); + + // Extract rate limit info from headers + const rateLimitInfo = extractRateLimitInfo(response.headers); + + if (!response.ok) { + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after'); + throw new Error( + `Rate limited (429)${retryAfter ? `, retry after ${retryAfter}s` : ''}` + ); + } + const errorBody = await response.text(); + throw new Error(`CDP API error ${response.status}: ${errorBody}`); + } + + return { data: (await response.json()) as T, rateLimitInfo }; +} + +interface RateLimitInfo { + limit?: number; + remaining?: number; + reset?: number; + retryAfter?: number; +} + +function extractRateLimitInfo(headers: Headers): RateLimitInfo | undefined { + const info: RateLimitInfo = {}; + + // Common rate limit headers + const limit = + headers.get('x-ratelimit-limit') ?? headers.get('ratelimit-limit'); + const remaining = + headers.get('x-ratelimit-remaining') ?? headers.get('ratelimit-remaining'); + const reset = + headers.get('x-ratelimit-reset') ?? headers.get('ratelimit-reset'); + const retryAfter = headers.get('retry-after'); + + if (limit) info.limit = parseInt(limit, 10); + if (remaining) info.remaining = parseInt(remaining, 10); + if (reset) info.reset = parseInt(reset, 10); + if (retryAfter) info.retryAfter = parseInt(retryAfter, 10); + + return Object.keys(info).length > 0 ? info : undefined; +} + +// Track rate limit info globally for logging +let lastRateLimitInfo: RateLimitInfo | undefined; + +/** + * Fetch a page of resources from CDP with authentication + */ +async function listCdpResourcesAuthenticated( + config?: ListDiscoveryResourcesRequest +): Promise { + const params = new URLSearchParams(); + if (config?.offset !== undefined) params.set('offset', String(config.offset)); + if (config?.limit !== undefined) params.set('limit', String(config.limit)); + + const queryString = params.toString(); + const path = queryString + ? `${CDP_DISCOVERY_PATH}?${queryString}` + : CDP_DISCOVERY_PATH; + + const { data, rateLimitInfo } = + await cdpAuthenticatedFetch(path); + + if (rateLimitInfo) { + lastRateLimitInfo = rateLimitInfo; + } + + return data; +} + +// ============================================================================ +// Resource Fetching +// ============================================================================ + +/** + * Fetches a single page of resources from a facilitator. + */ +async function listFacilitatorResources( + facilitator: FacilitatorConfig, + config?: ListDiscoveryResourcesRequest +): Promise { + // Use authenticated fetch for CDP if credentials available + if (facilitator.url.includes(CDP_HOST) && hasCdpCredentials()) { + return listCdpResourcesAuthenticated(config); + } + + // Some facilitators (like anyspend) wrap response in { data: { items, pagination } } + // Try direct fetch to handle non-standard responses + if (facilitator.url.includes('anyspend')) { + return fetchAnyspendResources(facilitator, config); + } + + const result = await facilitatorUtils(facilitator).list(config); + + // Validate response structure + if (!result || !Array.isArray(result.items)) { + throw new Error( + `Invalid response format: expected { items: [], pagination: {} }, got ${JSON.stringify(result).slice(0, 200)}` + ); + } + + return result; +} + +/** + * Fetch from anyspend which wraps response in { data: { items, pagination } } + */ +async function fetchAnyspendResources( + facilitator: FacilitatorConfig, + config?: ListDiscoveryResourcesRequest +): Promise { + const params = new URLSearchParams(); + if (config?.offset !== undefined) params.set('offset', String(config.offset)); + if (config?.limit !== undefined) params.set('limit', String(config.limit)); + + const queryString = params.toString(); + const url = `${facilitator.url}/discovery/resources${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Anyspend API error ${response.status}`); + } + + const json = (await response.json()) as { + success: boolean; + data: ListDiscoveryResourcesResponse; + }; + + if (!json.success || !json.data?.items) { + throw new Error(`Anyspend returned unsuccessful response`); + } + + return json.data; +} + +/** + * Fetches all resources from a facilitator with rate-limit handling. + */ +async function listAllFacilitatorResources( + facilitator: FacilitatorConfig, + options: FetchOptions = {} +): Promise { + const { delayMs = 100, onProgress } = options; + const allItems: FacilitatorResource[] = []; + let offset = 0; + let hasMore = true; + let backoff = 1000; + const maxBackoff = 32000; + + // CDP has aggressive rate limits even with auth, use provided delay + const effectiveDelay = delayMs; + + while (hasMore) { + try { + const { pagination, items } = await listFacilitatorResources( + facilitator, + { + offset, + limit: 100, + } + ); + allItems.push(...items); + + onProgress?.(allItems.length, pagination.total); + + if (pagination.total > pagination.offset + pagination.limit) { + hasMore = true; + offset += pagination.limit; + if (effectiveDelay > 0) { + await new Promise(res => setTimeout(res, effectiveDelay)); + } + } else { + hasMore = false; + } + backoff = 1000; + } catch (err) { + const isRateLimit = + err instanceof Error && + (err.message.toLowerCase().includes('429') || + err.message.toLowerCase().includes('rate')); + + if (isRateLimit) { + // Check for retry-after in error message + const retryMatch = /retry after (\d+)s/.exec(err.message); + const retryAfter = retryMatch?.[1] + ? parseInt(retryMatch[1], 10) * 1000 + : null; + const waitTime = retryAfter ?? backoff; + + // Log rate limit info if available + if (lastRateLimitInfo) { + log( + 'dim', + ` Rate limit: ${lastRateLimitInfo.remaining ?? '?'}/${lastRateLimitInfo.limit ?? '?'} remaining` + ); + } + + log('yellow', ` ā³ Rate limited, retrying in ${waitTime / 1000}s...`); + await new Promise(res => setTimeout(res, waitTime)); + backoff = retryAfter ? 1000 : Math.min(backoff * 2, maxBackoff); + continue; + } else { + throw err; + } + } + } + return allItems; +} + +// ============================================================================ +// Console Output +// ============================================================================ + +const COLORS = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + magenta: '\x1b[35m', + red: '\x1b[31m', +}; + +function log(color: keyof typeof COLORS, ...args: unknown[]) { + console.log(COLORS[color], ...args, COLORS.reset); +} + +function printFacilitator( + facilitator: (typeof discoverableFacilitators)[0], + isAuthenticated: boolean +) { + log('cyan', `\n${'─'.repeat(60)}`); + const authBadge = isAuthenticated ? ' šŸ”' : ''; + log('bright', `šŸ“” ${facilitator.url}${authBadge}`); +} + +function printResourceSummary(resources: FacilitatorResource[]) { + const byType = resources.reduce( + (acc, r) => { + acc[r.type] = (acc[r.type] ?? 0) + 1; + return acc; + }, + {} as Record + ); + + const networks = new Set( + resources.flatMap(r => r.accepts.map(a => a.network)) + ); + + log('green', ` Resources: ${resources.length}`); + log('dim', ` By type: ${JSON.stringify(byType)}`); + log('dim', ` Networks: ${Array.from(networks).join(', ')}`); +} + +function printResources(resources: FacilitatorResource[]) { + for (const resource of resources.slice(0, 10)) { + log('yellow', `\n šŸ”— ${resource.resource}`); + log('dim', ` Type: ${resource.type}`); + log('dim', ` x402 Version: ${resource.x402Version}`); + log('dim', ` Last Updated: ${String(resource.lastUpdated)}`); + + for (const accept of resource.accepts) { + log('magenta', ` šŸ’° ${accept.network} - ${accept.scheme}`); + log('dim', ` Pay To: ${accept.payTo}`); + log('dim', ` Max Amount: ${accept.maxAmountRequired}`); + log('dim', ` Asset: ${accept.asset}`); + if (accept.description) { + log( + 'dim', + ` Description: ${accept.description.slice(0, 100)}...` + ); + } + } + } + + if (resources.length > 10) { + log('dim', `\n ... and ${resources.length - 10} more resources`); + } +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main() { + const args = process.argv.slice(2); + const listOnly = args.includes('--list-only'); + const facilitatorFilter = args + .find(a => a.startsWith('--facilitator=')) + ?.split('=')[1]; + const skipFilter = args.find(a => a.startsWith('--skip='))?.split('=')[1]; + const verbose = args.includes('--verbose') || args.includes('-v'); + const delayArg = args.find(a => a.startsWith('--delay='))?.split('=')[1]; + const delayMs = delayArg ? parseInt(delayArg, 10) : 500; + + log('bright', '\nšŸ” Discoverable Facilitators\n'); + log('dim', `Found ${discoverableFacilitators.length} facilitators`); + + if (hasCdpCredentials()) { + log( + 'green', + `CDP credentials detected - authenticated requests enabled šŸ”\n` + ); + } else { + log( + 'yellow', + `No CDP credentials - using unauthenticated requests (may be rate limited)\n` + ); + log( + 'dim', + `Set CDP_API_KEY_ID and CDP_API_KEY_SECRET for authenticated access\n` + ); + } + + let facilitators = discoverableFacilitators; + + if (facilitatorFilter) { + facilitators = facilitators.filter(f => + f.url.toLowerCase().includes(facilitatorFilter.toLowerCase()) + ); + if (facilitators.length === 0) { + log('red', `No facilitators found matching "${facilitatorFilter}"`); + process.exit(1); + } + } + + if (skipFilter) { + const skips = skipFilter.split(','); + facilitators = facilitators.filter( + f => !skips.some(skip => f.url.toLowerCase().includes(skip.toLowerCase())) + ); + } + + if (listOnly) { + for (const facilitator of facilitators) { + const isAuth = facilitator.url.includes(CDP_HOST) && hasCdpCredentials(); + printFacilitator(facilitator, isAuth); + } + return; + } + + let totalResources = 0; + + for (const facilitator of facilitators) { + const isAuthenticated = + facilitator.url.includes(CDP_HOST) && hasCdpCredentials(); + printFacilitator(facilitator, isAuthenticated); + + try { + const startTime = Date.now(); + const resources = await listAllFacilitatorResources(facilitator, { + delayMs, + onProgress: (count, total) => { + process.stdout.write( + `\r šŸ“„ Fetching: ${count}/${total} resources...` + ); + }, + }); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + const duration = Date.now() - startTime; + + printResourceSummary(resources); + log('dim', ` Fetched in ${(duration / 1000).toFixed(1)}s`); + + if (verbose) { + printResources(resources); + } + + totalResources += resources.length; + } catch (error) { + log( + 'red', + ` āŒ Error: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + log('cyan', `\n${'─'.repeat(60)}`); + log( + 'bright', + `\nšŸ“Š Total: ${totalResources} resources from ${facilitators.length} facilitators\n` + ); +} + +main().catch(console.error); diff --git a/apps/scan/src/services/db/resources/resource.ts b/apps/scan/src/services/db/resources/resource.ts index e841fe6fd..70fbf23d1 100644 --- a/apps/scan/src/services/db/resources/resource.ts +++ b/apps/scan/src/services/db/resources/resource.ts @@ -59,7 +59,18 @@ export const upsertResourceSchema = z.object({ ]), payTo: mixedAddressSchema, description: z.string(), - maxAmountRequired: z.string(), + maxAmountRequired: z.string().refine( + v => { + if (!v || v.trim() === '') return false; + try { + BigInt(v); + return true; + } catch { + return false; + } + }, + { message: 'maxAmountRequired must be a valid numeric string' } + ), mimeType: z.string(), maxTimeoutSeconds: z.number(), asset: z.string(), diff --git a/apps/scan/vercel.json b/apps/scan/vercel.json index 6e30cabec..792ffc687 100644 --- a/apps/scan/vercel.json +++ b/apps/scan/vercel.json @@ -29,8 +29,8 @@ "schedule": "*/1 * * * *" }, { - "path": "/api/resources/sync", - "schedule": "*/1 * * * *" + "path": "/api/resources/sync?skip=coinbase", + "schedule": "*/10 * * * *" }, { "path": "/api/resources/label", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aef9a18dd..c5b0ea592 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,6 +394,12 @@ importers: '@wallet-standard/react': specifier: ^1.0.1 version: 1.0.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@x402/core': + specifier: ^2.2.0 + version: 2.2.0 + '@x402/extensions': + specifier: ^2.2.0 + version: 2.2.0 '@x402scan/analytics-db': specifier: workspace:* version: link:../../packages/internal/databases/analytics @@ -5886,6 +5892,12 @@ packages: '@walletconnect/window-metadata@1.0.1': resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + '@x402/core@2.2.0': + resolution: {integrity: sha512-UyPX7UVrqCyFTMeDWAx9cn9LvcaRlUoAknSehuxJ07vXLVhC7Wx5R1h2CV12YkdB+hE6K48Qvfd4qrvbpqqYfw==} + + '@x402/extensions@2.2.0': + resolution: {integrity: sha512-WipmXWmp2T9ES9skFwzgLNoh+1xWZtHXxWTcoykdQZ9POPZBpewGkrcZ/+wf9/ml+pfeYAUHXwPbVkyJsAKTIA==} + abitype@1.0.6: resolution: {integrity: sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==} peerDependencies: @@ -5990,6 +6002,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@7.1.1: resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==} engines: {node: '>=18'} @@ -7460,6 +7475,9 @@ packages: fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastestsmallesttextencoderdecoder@1.0.22: resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} @@ -8237,6 +8255,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -9694,6 +9715,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} @@ -12307,6 +12332,14 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@gemini-wallet/core@0.3.1(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + dependencies: + '@metamask/rpc-errors': 7.0.2 + eventemitter3: 5.0.1 + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@gemini-wallet/core@0.3.1(viem@2.38.5(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 @@ -12319,7 +12352,7 @@ snapshots: dependencies: '@metamask/rpc-errors': 7.0.2 eventemitter3: 5.0.1 - viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) transitivePeerDependencies: - supports-color @@ -17729,7 +17762,7 @@ snapshots: '@walletconnect/ethereum-provider': 2.21.1(bufferutil@4.0.9)(react@19.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' porto: 0.2.35(@tanstack/react-query@5.90.16(react@19.2.2))(@wagmi/core@2.22.1(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(wagmi@2.19.1(@tanstack/react-query@5.90.16(react@19.2.2))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76)) - viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -17775,7 +17808,7 @@ snapshots: dependencies: '@base-org/account': 2.4.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(use-sync-external-store@1.4.0)(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(bufferutil@4.0.9)(typescript@5.9.3)(use-sync-external-store@1.4.0)(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.1(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@gemini-wallet/core': 0.3.1(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@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) @@ -17844,7 +17877,7 @@ snapshots: dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) zustand: 5.0.0(@types/react@19.2.2)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) optionalDependencies: typescript: 5.9.3 @@ -19079,6 +19112,16 @@ snapshots: '@walletconnect/window-getters': 1.0.1 tslib: 1.14.1 + '@x402/core@2.2.0': + dependencies: + zod: 3.25.76 + + '@x402/extensions@2.2.0': + dependencies: + '@x402/core': 2.2.0 + ajv: 8.17.1 + zod: 3.25.76 + abitype@1.0.6(typescript@5.9.3)(zod@3.25.76): optionalDependencies: typescript: 5.9.3 @@ -19181,6 +19224,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.1.1: dependencies: environment: 1.1.0 @@ -21089,6 +21139,8 @@ snapshots: fast-stable-stringify@1.0.0: {} + fast-uri@3.1.0: {} + fastestsmallesttextencoderdecoder@1.0.22: {} fastq@1.19.1: @@ -21973,6 +22025,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -23398,7 +23452,7 @@ snapshots: idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.9.3) ox: 0.9.14(typescript@5.9.3)(zod@4.3.5) - viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) zod: 4.3.5 zustand: 5.0.8(@types/react@19.2.2)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) optionalDependencies: @@ -23899,6 +23953,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@7.5.2(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -25527,7 +25583,7 @@ snapshots: '@wagmi/core': 2.22.1(react@19.2.2)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)) react: 19.2.2 use-sync-external-store: 1.4.0(react@19.2.2) - viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: