From 9508ba62663e5dc822e2d4ce631248dce0d918ab Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 12:16:56 +1100 Subject: [PATCH 01/46] feat: add public swap API server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a public API for swap quotes and rates that reuses the existing @shapeshiftoss/swapper package without code duplication. Endpoints: - GET /v1/swap/rates - Get rates from all swappers - POST /v1/swap/quote - Get executable quote with tx data - GET /v1/assets - List supported assets Features: - API key authentication middleware - Minimal EVM chain adapters for gas fee estimation - esbuild bundling for single-file deployment - Docker + Railway deployment configuration - 55 bps affiliate fee (planned 50/50 split with partners) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/.env.example | 21 + packages/public-api/Dockerfile | 59 + packages/public-api/esbuild.config.mjs | 27 + packages/public-api/package.json | 40 + packages/public-api/railway.toml | 17 + packages/public-api/src/assets.ts | 74 ++ packages/public-api/src/config.ts | 49 + packages/public-api/src/index.ts | 70 ++ packages/public-api/src/middleware/auth.ts | 91 ++ packages/public-api/src/routes/assets.ts | 92 ++ packages/public-api/src/routes/quote.ts | 260 +++++ packages/public-api/src/routes/rates.ts | 183 +++ packages/public-api/src/server-standalone.ts | 250 ++++ packages/public-api/src/swapperDeps.ts | 139 +++ packages/public-api/src/types.ts | 138 +++ packages/public-api/tsconfig.json | 20 + railway.public-api.toml | 22 + yarn.lock | 1074 ++++++++++++++++-- 18 files changed, 2546 insertions(+), 80 deletions(-) create mode 100644 packages/public-api/.env.example create mode 100644 packages/public-api/Dockerfile create mode 100644 packages/public-api/esbuild.config.mjs create mode 100644 packages/public-api/package.json create mode 100644 packages/public-api/railway.toml create mode 100644 packages/public-api/src/assets.ts create mode 100644 packages/public-api/src/config.ts create mode 100644 packages/public-api/src/index.ts create mode 100644 packages/public-api/src/middleware/auth.ts create mode 100644 packages/public-api/src/routes/assets.ts create mode 100644 packages/public-api/src/routes/quote.ts create mode 100644 packages/public-api/src/routes/rates.ts create mode 100644 packages/public-api/src/server-standalone.ts create mode 100644 packages/public-api/src/swapperDeps.ts create mode 100644 packages/public-api/src/types.ts create mode 100644 packages/public-api/tsconfig.json create mode 100644 railway.public-api.toml diff --git a/packages/public-api/.env.example b/packages/public-api/.env.example new file mode 100644 index 00000000000..d800eaf6015 --- /dev/null +++ b/packages/public-api/.env.example @@ -0,0 +1,21 @@ +# Server Configuration +PORT=3001 +HOST=0.0.0.0 +NODE_ENV=production + +# Asset Data Path (optional - will auto-detect if not set) +# ASSET_DATA_PATH=/app/public/generated/generatedAssetData.json + +# API Configuration (optional - defaults shown) +# VITE_PORTALS_API_KEY= +# VITE_CHAINALYSIS_API_KEY= +# VITE_ZRX_API_KEY= +# VITE_ZERION_API_KEY= + +# Unchained URLs (defaults to ShapeShift public instances) +# UNCHAINED_ETHEREUM_HTTP_URL=https://api.ethereum.shapeshift.com +# UNCHAINED_THORCHAIN_HTTP_URL=https://api.thorchain.shapeshift.com + +# Partner API Keys (add your partner keys here) +# Format: API_KEY_=:: +# Example: API_KEY_PARTNER1=abc123:MyPartner:50 diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile new file mode 100644 index 00000000000..58801bd0420 --- /dev/null +++ b/packages/public-api/Dockerfile @@ -0,0 +1,59 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache python3 make g++ + +# Copy workspace files +COPY package.json yarn.lock .yarnrc.yml ./ +COPY .yarn ./.yarn + +# Copy all package.json files for workspace resolution +COPY packages/public-api/package.json ./packages/public-api/ +COPY packages/caip/package.json ./packages/caip/ +COPY packages/types/package.json ./packages/types/ +COPY packages/utils/package.json ./packages/utils/ +COPY packages/swapper/package.json ./packages/swapper/ +COPY packages/chain-adapters/package.json ./packages/chain-adapters/ +COPY packages/unchained-client/package.json ./packages/unchained-client/ +COPY packages/contracts/package.json ./packages/contracts/ +COPY packages/errors/package.json ./packages/errors/ + +# Install dependencies +RUN yarn install --immutable + +# Copy source files +COPY packages/ ./packages/ +COPY public/generated/generatedAssetData.json ./public/generated/ + +# Build packages that public-api depends on +RUN yarn workspaces foreach -Rt --from @shapeshiftoss/public-api run build + +# Build the public-api bundle +WORKDIR /app/packages/public-api +RUN yarn node esbuild.config.mjs + +# Production stage +FROM node:22-alpine AS runner + +WORKDIR /app + +# Copy the bundled server and asset data +COPY --from=builder /app/packages/public-api/dist/server.cjs ./server.cjs +COPY --from=builder /app/public/generated/generatedAssetData.json ./public/generated/ + +# Set environment +ENV NODE_ENV=production +ENV ASSET_DATA_PATH=/app/public/generated/generatedAssetData.json +ENV PORT=3001 +ENV HOST=0.0.0.0 + +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 + +CMD ["node", "server.cjs"] diff --git a/packages/public-api/esbuild.config.mjs b/packages/public-api/esbuild.config.mjs new file mode 100644 index 00000000000..3809a39145b --- /dev/null +++ b/packages/public-api/esbuild.config.mjs @@ -0,0 +1,27 @@ +import * as esbuild from 'esbuild' + +const result = await esbuild.build({ + entryPoints: ['src/index.ts'], + bundle: true, + outfile: 'dist/server.cjs', + platform: 'node', + target: 'node20', + format: 'cjs', + sourcemap: true, + external: [ + // Keep native modules external + 'fsevents', + // Exclude all cowswap-related packages that have ethers import issues + '@cowprotocol/*', + ], + alias: { + // Alias ethers/lib/utils to ethers which has the utils re-exported + 'ethers/lib/utils': 'ethers', + }, + define: { + 'process.env.NODE_ENV': '"production"', + }, + logLevel: 'info', +}) + +console.log('Build complete:', result) diff --git a/packages/public-api/package.json b/packages/public-api/package.json new file mode 100644 index 00000000000..e02a46806c8 --- /dev/null +++ b/packages/public-api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@shapeshiftoss/public-api", + "version": "0.1.0", + "packageManager": "yarn@3.5.0", + "repository": "https://github.com/shapeshift/web", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "yarn clean && yarn run -T tsc --build", + "build:bundle": "node esbuild.config.mjs", + "clean": "rm -rf dist", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "start:prod": "node dist/server.cjs", + "docker:build": "docker build -t shapeshift-public-api -f Dockerfile ../../", + "docker:run": "docker run -p 3001:3001 --env-file .env shapeshift-public-api" + }, + "dependencies": { + "@shapeshiftoss/caip": "workspace:^", + "@shapeshiftoss/chain-adapters": "workspace:^", + "@shapeshiftoss/swapper": "workspace:^", + "@shapeshiftoss/types": "workspace:^", + "@shapeshiftoss/utils": "workspace:^", + "@sniptt/monads": "^0.5.10", + "cors": "^2.8.5", + "express": "^4.21.0", + "uuid": "^9.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.10.4", + "@types/uuid": "^9.0.5", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/packages/public-api/railway.toml b/packages/public-api/railway.toml new file mode 100644 index 00000000000..ffb151bae00 --- /dev/null +++ b/packages/public-api/railway.toml @@ -0,0 +1,17 @@ +[build] +builder = "dockerfile" +dockerfilePath = "packages/public-api/Dockerfile" +watchPatterns = [ + "packages/public-api/**", + "packages/swapper/**", + "packages/caip/**", + "packages/types/**", + "packages/utils/**", + "public/generated/generatedAssetData.json" +] + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 5 diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts new file mode 100644 index 00000000000..5eeb88f0488 --- /dev/null +++ b/packages/public-api/src/assets.ts @@ -0,0 +1,74 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import type { Asset, AssetsById } from '@shapeshiftoss/types' +import { getBaseAsset } from '@shapeshiftoss/utils' +import fs from 'fs' +import path from 'path' + +let assetsById: AssetsById = {} +let assetIds: AssetId[] = [] +let assets: Asset[] = [] +let initialized = false + +export const initAssets = async (): Promise => { + if (initialized) return + + try { + // Try to load from the generated asset data file + // First check env var, then relative to cwd, then relative to monorepo root + const possiblePaths = [ + process.env.ASSET_DATA_PATH, + path.join(process.cwd(), 'public/generated/generatedAssetData.json'), + path.join(process.cwd(), '../../public/generated/generatedAssetData.json'), + path.join(process.cwd(), 'generatedAssetData.json'), + ].filter(Boolean) as string[] + + let assetDataPath: string | undefined + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + assetDataPath = p + break + } + } + + if (!assetDataPath) { + console.warn('Asset data file not found in any of the expected locations:', possiblePaths) + initialized = true + return + } + + const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, 'utf8')) + const localAssetData = assetDataJson.byId + const sortedAssetIds = assetDataJson.ids as AssetId[] + + // Enrich assets with chain-level data + const enrichedAssetsById: AssetsById = {} + for (const assetId of sortedAssetIds) { + const asset = localAssetData[assetId] + if (asset) { + const baseAsset = getBaseAsset(asset.chainId) + enrichedAssetsById[assetId] = { + ...asset, + networkName: baseAsset?.networkName, + explorer: baseAsset?.explorer, + explorerAddressLink: baseAsset?.explorerAddressLink, + explorerTxLink: baseAsset?.explorerTxLink, + } + } + } + + assetsById = enrichedAssetsById + assetIds = sortedAssetIds + assets = sortedAssetIds.map((id: AssetId) => enrichedAssetsById[id]).filter(Boolean) as Asset[] + + console.log(`Loaded ${assetIds.length} assets from ${assetDataPath}`) + } catch (error) { + console.error('Failed to load assets:', error) + } + + initialized = true +} + +export const getAssetsById = (): AssetsById => assetsById +export const getAssetIds = (): AssetId[] => assetIds +export const getAllAssets = (): Asset[] => assets +export const getAsset = (assetId: AssetId): Asset | undefined => assetsById[assetId] diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts new file mode 100644 index 00000000000..ae154d9935a --- /dev/null +++ b/packages/public-api/src/config.ts @@ -0,0 +1,49 @@ +import type { SwapperConfig } from '@shapeshiftoss/swapper' + +// Server-side config that mirrors the web app's config but from environment variables +export const getServerConfig = (): SwapperConfig => ({ + VITE_UNCHAINED_THORCHAIN_HTTP_URL: process.env.UNCHAINED_THORCHAIN_HTTP_URL || 'https://api.thorchain.shapeshift.com', + VITE_UNCHAINED_MAYACHAIN_HTTP_URL: process.env.UNCHAINED_MAYACHAIN_HTTP_URL || 'https://api.mayachain.shapeshift.com', + VITE_UNCHAINED_COSMOS_HTTP_URL: process.env.UNCHAINED_COSMOS_HTTP_URL || 'https://api.cosmos.shapeshift.com', + VITE_THORCHAIN_NODE_URL: process.env.THORCHAIN_NODE_URL || 'https://thornode.ninerealms.com', + VITE_MAYACHAIN_NODE_URL: process.env.MAYACHAIN_NODE_URL || 'https://tendermint.mayachain.info', + VITE_TRON_NODE_URL: process.env.TRON_NODE_URL || 'https://api.trongrid.io', + VITE_FEATURE_THORCHAINSWAP_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_LONGTAIL === 'true', + VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL === 'true', + VITE_THORCHAIN_MIDGARD_URL: process.env.THORCHAIN_MIDGARD_URL || 'https://midgard.thorchain.info/v2', + VITE_MAYACHAIN_MIDGARD_URL: process.env.MAYACHAIN_MIDGARD_URL || 'https://midgard.mayachain.info/v2', + VITE_UNCHAINED_BITCOIN_HTTP_URL: process.env.UNCHAINED_BITCOIN_HTTP_URL || 'https://api.bitcoin.shapeshift.com', + VITE_UNCHAINED_DOGECOIN_HTTP_URL: process.env.UNCHAINED_DOGECOIN_HTTP_URL || 'https://api.dogecoin.shapeshift.com', + VITE_UNCHAINED_LITECOIN_HTTP_URL: process.env.UNCHAINED_LITECOIN_HTTP_URL || 'https://api.litecoin.shapeshift.com', + VITE_UNCHAINED_BITCOINCASH_HTTP_URL: process.env.UNCHAINED_BITCOINCASH_HTTP_URL || 'https://api.bitcoincash.shapeshift.com', + VITE_UNCHAINED_ETHEREUM_HTTP_URL: process.env.UNCHAINED_ETHEREUM_HTTP_URL || 'https://api.ethereum.shapeshift.com', + VITE_UNCHAINED_AVALANCHE_HTTP_URL: process.env.UNCHAINED_AVALANCHE_HTTP_URL || 'https://api.avalanche.shapeshift.com', + VITE_UNCHAINED_BNBSMARTCHAIN_HTTP_URL: process.env.UNCHAINED_BNBSMARTCHAIN_HTTP_URL || 'https://api.bnbsmartchain.shapeshift.com', + VITE_UNCHAINED_BASE_HTTP_URL: process.env.UNCHAINED_BASE_HTTP_URL || 'https://api.base.shapeshift.com', + VITE_COWSWAP_BASE_URL: process.env.COWSWAP_BASE_URL || 'https://api.cow.fi', + VITE_PORTALS_BASE_URL: process.env.PORTALS_BASE_URL || 'https://api.portals.fi', + VITE_ZRX_BASE_URL: process.env.ZRX_BASE_URL || 'https://api.proxy.shapeshift.com/api/v1/zrx/', + VITE_CHAINFLIP_API_KEY: process.env.CHAINFLIP_API_KEY || '', + VITE_CHAINFLIP_API_URL: process.env.CHAINFLIP_API_URL || 'https://chainflip-broker.io', + VITE_FEATURE_CHAINFLIP_SWAP_DCA: process.env.FEATURE_CHAINFLIP_SWAP_DCA === 'true', + VITE_JUPITER_API_URL: process.env.JUPITER_API_URL || 'https://quote-api.jup.ag/v6', + VITE_RELAY_API_URL: process.env.RELAY_API_URL || 'https://api.relay.link', + VITE_BEBOP_API_KEY: process.env.BEBOP_API_KEY || '', + VITE_NEAR_INTENTS_API_KEY: process.env.NEAR_INTENTS_API_KEY || '', + VITE_TENDERLY_API_KEY: process.env.TENDERLY_API_KEY || '', + VITE_TENDERLY_ACCOUNT_SLUG: process.env.TENDERLY_ACCOUNT_SLUG || '', + VITE_TENDERLY_PROJECT_SLUG: process.env.TENDERLY_PROJECT_SLUG || '', + VITE_SUI_NODE_URL: process.env.SUI_NODE_URL || 'https://fullnode.mainnet.sui.io', +}) + +// Default affiliate fee in basis points +export const DEFAULT_AFFILIATE_BPS = '55' + +// API server config +export const API_PORT = parseInt(process.env.PORT || '3001', 10) +export const API_HOST = process.env.HOST || '0.0.0.0' + +// Static API keys for testing (in production these would come from a database) +export const STATIC_API_KEYS: Record = { + 'test-api-key-123': { name: 'Test Partner', feeSharePercentage: 50 }, +} diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts new file mode 100644 index 00000000000..3d74e3c8e20 --- /dev/null +++ b/packages/public-api/src/index.ts @@ -0,0 +1,70 @@ +import express from 'express' +import cors from 'cors' + +import { API_PORT, API_HOST } from './config' +import { initAssets } from './assets' +import { apiKeyAuth, optionalApiKeyAuth } from './middleware/auth' +import { getRates } from './routes/rates' +import { getQuote } from './routes/quote' +import { getAssets, getAssetById, getAssetCount } from './routes/assets' + +const app = express() + +// Middleware +app.use(cors()) +app.use(express.json()) + +// Health check (no auth required) +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: Date.now() }) +}) + +// API v1 routes +const v1Router = express.Router() + +// Swap endpoints (require API key) +v1Router.get('/swap/rates', apiKeyAuth, getRates) +v1Router.post('/swap/quote', apiKeyAuth, getQuote) + +// Asset endpoints (optional auth) +v1Router.get('/assets', optionalApiKeyAuth, getAssets) +v1Router.get('/assets/count', optionalApiKeyAuth, getAssetCount) +v1Router.get('/assets/:assetId(*)', optionalApiKeyAuth, getAssetById) + +app.use('/v1', v1Router) + +// 404 handler +app.use((_req, res) => { + res.status(404).json({ error: 'Not found' }) +}) + +// Error handler +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('Unhandled error:', err) + res.status(500).json({ error: 'Internal server error' }) +}) + +// Start server +const startServer = async () => { + console.log('Initializing assets...') + await initAssets() + + app.listen(API_PORT, API_HOST, () => { + console.log(`Public API server running at http://${API_HOST}:${API_PORT}`) + console.log(` +Available endpoints: + GET /health - Health check + GET /v1/swap/rates - Get swap rates from all swappers + POST /v1/swap/quote - Get executable quote with tx data + GET /v1/assets - List supported assets + GET /v1/assets/count - Get asset count + GET /v1/assets/:assetId - Get single asset by ID + +Authentication: + Include 'X-API-Key' header with your API key for /v1/swap/* endpoints. + Test API key: test-api-key-123 + `) + }) +} + +startServer().catch(console.error) diff --git a/packages/public-api/src/middleware/auth.ts b/packages/public-api/src/middleware/auth.ts new file mode 100644 index 00000000000..eaa7edff3cd --- /dev/null +++ b/packages/public-api/src/middleware/auth.ts @@ -0,0 +1,91 @@ +import type { Request, Response, NextFunction } from 'express' +import crypto from 'crypto' + +import { STATIC_API_KEYS } from '../config' +import type { PartnerConfig, ErrorResponse } from '../types' + +// Hash an API key for comparison (in production, keys would be stored hashed) +const hashApiKey = (key: string): string => { + return crypto.createHash('sha256').update(key).digest('hex') +} + +// API key authentication middleware +export const apiKeyAuth = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + const apiKey = req.header('X-API-Key') + + if (!apiKey) { + const errorResponse: ErrorResponse = { + error: 'API key required', + code: 'MISSING_API_KEY', + } + res.status(401).json(errorResponse) + return + } + + // Look up the partner by API key + // In production, this would query a database + const partnerInfo = STATIC_API_KEYS[apiKey] + + if (!partnerInfo) { + const errorResponse: ErrorResponse = { + error: 'Invalid API key', + code: 'INVALID_API_KEY', + } + res.status(401).json(errorResponse) + return + } + + // Attach partner info to request + const partner: PartnerConfig = { + id: hashApiKey(apiKey).substring(0, 16), // Use hash prefix as ID + apiKeyHash: hashApiKey(apiKey), + name: partnerInfo.name, + feeSharePercentage: partnerInfo.feeSharePercentage, + status: 'active', + rateLimit: { + requestsPerMinute: 60, + requestsPerDay: 10000, + }, + createdAt: new Date(), + } + + req.partner = partner + + next() +} + +// Optional auth - allows unauthenticated requests but attaches partner info if present +export const optionalApiKeyAuth = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + const apiKey = req.header('X-API-Key') + + if (apiKey) { + const partnerInfo = STATIC_API_KEYS[apiKey] + + if (partnerInfo) { + const partner: PartnerConfig = { + id: hashApiKey(apiKey).substring(0, 16), + apiKeyHash: hashApiKey(apiKey), + name: partnerInfo.name, + feeSharePercentage: partnerInfo.feeSharePercentage, + status: 'active', + rateLimit: { + requestsPerMinute: 60, + requestsPerDay: 10000, + }, + createdAt: new Date(), + } + + req.partner = partner + } + } + + next() +} diff --git a/packages/public-api/src/routes/assets.ts b/packages/public-api/src/routes/assets.ts new file mode 100644 index 00000000000..d6214d7e6d5 --- /dev/null +++ b/packages/public-api/src/routes/assets.ts @@ -0,0 +1,92 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { getAllAssets, getAsset, getAssetIds } from '../assets' +import type { AssetsResponse, ErrorResponse } from '../types' + +// Request validation schema for single asset +const AssetRequestSchema = z.object({ + assetId: z.string().min(1), +}) + +// Request validation schema for filtered list +const AssetsListRequestSchema = z.object({ + chainId: z.string().optional(), + limit: z.coerce.number().min(1).max(1000).optional().default(100), + offset: z.coerce.number().min(0).optional().default(0), +}) + +export const getAssets = async (req: Request, res: Response): Promise => { + try { + const parseResult = AssetsListRequestSchema.safeParse(req.query) + if (!parseResult.success) { + const errorResponse: ErrorResponse = { + error: 'Invalid request parameters', + details: parseResult.error.errors, + } + res.status(400).json(errorResponse) + return + } + + const { chainId, limit, offset } = parseResult.data + + let assets = getAllAssets() + + // Filter by chain if specified + if (chainId) { + assets = assets.filter(asset => asset.chainId === chainId) + } + + // Apply pagination + const paginatedAssets = assets.slice(offset, offset + limit) + + const response: AssetsResponse = { + assets: paginatedAssets, + timestamp: Date.now(), + } + + res.json(response) + } catch (error) { + console.error('Error in getAssets:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + } +} + +export const getAssetById = async (req: Request, res: Response): Promise => { + try { + const parseResult = AssetRequestSchema.safeParse(req.params) + if (!parseResult.success) { + const errorResponse: ErrorResponse = { + error: 'Invalid request parameters', + details: parseResult.error.errors, + } + res.status(400).json(errorResponse) + return + } + + const { assetId } = parseResult.data + // URL decode the assetId since it contains special characters + const decodedAssetId = decodeURIComponent(assetId) + const asset = getAsset(decodedAssetId) + + if (!asset) { + res.status(404).json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse) + return + } + + res.json(asset) + } catch (error) { + console.error('Error in getAssetById:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + } +} + +export const getAssetCount = async (_req: Request, res: Response): Promise => { + try { + const count = getAssetIds().length + res.json({ count, timestamp: Date.now() }) + } catch (error) { + console.error('Error in getAssetCount:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts new file mode 100644 index 00000000000..3c61ae75874 --- /dev/null +++ b/packages/public-api/src/routes/quote.ts @@ -0,0 +1,260 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { v4 as uuidv4 } from 'uuid' +import type { TradeQuote, TradeQuoteStep, SwapperName } from '@shapeshiftoss/swapper' +import { fromChainId } from '@shapeshiftoss/caip' + +import { getAssetsById, getAsset } from '../assets' +import { DEFAULT_AFFILIATE_BPS } from '../config' +import { createServerSwapperDeps } from '../swapperDeps' +import type { QuoteResponse, ApiQuoteStep, ApprovalInfo, ErrorResponse } from '../types' + +// Lazy load swapper to avoid import issues at module load time +let swapperModule: typeof import('@shapeshiftoss/swapper') | null = null +const getSwapperModule = async () => { + if (!swapperModule) { + swapperModule = await import('@shapeshiftoss/swapper') + } + return swapperModule +} + +// Request validation schema - swapperName is string, validated later +const QuoteRequestSchema = z.object({ + sellAssetId: z.string().min(1), + buyAssetId: z.string().min(1), + sellAmountCryptoBaseUnit: z.string().min(1), + receiveAddress: z.string().min(1), + sendAddress: z.string().optional(), + swapperName: z.string().min(1), + slippageTolerancePercentageDecimal: z.string().optional(), + allowMultiHop: z.boolean().optional().default(true), + accountNumber: z.number().optional().default(0), +}) + +// Helper to extract transaction data from quote step +const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transactionData'] => { + // Check for various swapper-specific transaction metadata + if (step.zrxTransactionMetadata) { + return { + to: step.zrxTransactionMetadata.to, + data: step.zrxTransactionMetadata.data, + value: step.zrxTransactionMetadata.value, + gasLimit: step.zrxTransactionMetadata.gas, + } + } + + if (step.portalsTransactionMetadata) { + return { + to: step.portalsTransactionMetadata.to, + data: step.portalsTransactionMetadata.data, + value: step.portalsTransactionMetadata.value, + gasLimit: step.portalsTransactionMetadata.gasLimit, + } + } + + if (step.bebopTransactionMetadata) { + return { + to: step.bebopTransactionMetadata.to, + data: step.bebopTransactionMetadata.data, + value: step.bebopTransactionMetadata.value, + gasLimit: step.bebopTransactionMetadata.gas, + } + } + + if (step.butterSwapTransactionMetadata) { + return { + to: step.butterSwapTransactionMetadata.to, + data: step.butterSwapTransactionMetadata.data, + value: step.butterSwapTransactionMetadata.value, + gasLimit: step.butterSwapTransactionMetadata.gasLimit, + } + } + + // For THORChain/MAYAChain and other swappers, transaction is built differently + // The consumer will need to use the quote data to build the transaction + return undefined +} + +// Helper to build approval info +const buildApprovalInfo = (step: TradeQuoteStep, sellAssetId: string): ApprovalInfo => { + const { chainNamespace } = fromChainId(step.sellAsset.chainId) + + // Only EVM tokens need approval + if (chainNamespace !== 'eip155') { + return { isRequired: false, spender: '' } + } + + // Native assets don't need approval + if (step.sellAsset.assetId.includes('slip44:60')) { + return { isRequired: false, spender: '' } + } + + // If there's an allowance contract, approval may be needed + if (step.allowanceContract) { + return { + isRequired: true, // Consumer should check current allowance + spender: step.allowanceContract, + // Approval transaction data - consumer builds this themselves + // or we could provide it if needed + } + } + + return { isRequired: false, spender: '' } +} + +// Transform quote step to API format +const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ + sellAsset: step.sellAsset, + buyAsset: step.buyAsset, + sellAmountCryptoBaseUnit: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit: step.buyAmountAfterFeesCryptoBaseUnit, + allowanceContract: step.allowanceContract, + estimatedExecutionTimeMs: step.estimatedExecutionTimeMs, + source: step.source, + transactionData: extractTransactionData(step), +}) + +export const getQuote = async (req: Request, res: Response): Promise => { + try { + // Parse and validate request + const parseResult = QuoteRequestSchema.safeParse(req.body) + if (!parseResult.success) { + const errorResponse: ErrorResponse = { + error: 'Invalid request parameters', + details: parseResult.error.errors, + } + res.status(400).json(errorResponse) + return + } + + const { + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + receiveAddress, + sendAddress, + swapperName, + slippageTolerancePercentageDecimal, + allowMultiHop, + accountNumber, + } = parseResult.data + + // Lazy load swapper module + const { getTradeQuotes, swappers, SwapperName, getDefaultSlippageDecimalPercentageForSwapper } = await getSwapperModule() + + // Validate swapper name + const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as SwapperName | undefined + if (!validSwapperName) { + res.status(400).json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse) + return + } + + // Validate swapper exists + const swapper = swappers[validSwapperName] + if (!swapper) { + res.status(400).json({ error: `Swapper not available: ${swapperName}` } as ErrorResponse) + return + } + + // Get assets + const sellAsset = getAsset(sellAssetId) + const buyAsset = getAsset(buyAssetId) + + if (!sellAsset) { + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) + return + } + if (!buyAsset) { + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + return + } + + // Create swapper dependencies + const deps = createServerSwapperDeps(getAssetsById()) + + // Get default slippage if not provided + let slippage = slippageTolerancePercentageDecimal + if (!slippage) { + try { + slippage = getDefaultSlippageDecimalPercentageForSwapper(validSwapperName) + } catch { + slippage = '0.01' // 1% default fallback + } + } + + // Build quote input + const quoteInput = { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmountCryptoBaseUnit, + affiliateBps: DEFAULT_AFFILIATE_BPS, + allowMultiHop, + slippageTolerancePercentageDecimal: slippage, + receiveAddress, + sendAddress, + accountNumber, + quoteOrRate: 'quote' as const, + chainId: sellAsset.chainId, + // EVM-specific fields + supportsEIP1559: true, + } + + // Fetch quote + const result = await getTradeQuotes(quoteInput, validSwapperName, deps) + + if (!result) { + res.status(404).json({ error: 'No quote available from this swapper' } as ErrorResponse) + return + } + + if (result.isErr()) { + const error = result.unwrapErr() + res.status(400).json({ + error: error.message, + code: error.code, + details: error.details, + } as ErrorResponse) + return + } + + const quotes = result.unwrap() + if (quotes.length === 0) { + res.status(404).json({ error: 'No quote available' } as ErrorResponse) + return + } + + // Use the first/best quote + const quote = quotes[0] as TradeQuote + const firstStep = quote.steps[0] + + // Calculate total buy amount (sum of all steps for multi-hop) + const lastStep = quote.steps[quote.steps.length - 1] + + // Build response + const quoteId = uuidv4() + const now = Date.now() + + const response: QuoteResponse = { + quoteId, + swapperName: validSwapperName, + rate: quote.rate, + sellAsset, + buyAsset, + sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountBeforeFeesCryptoBaseUnit: lastStep.buyAmountBeforeFeesCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit: lastStep.buyAmountAfterFeesCryptoBaseUnit, + affiliateBps: quote.affiliateBps, + slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, + networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, + steps: quote.steps.map(transformQuoteStep), + approval: buildApprovalInfo(firstStep, sellAssetId), + expiresAt: now + 60_000, // 60 second expiry + quote, // Include full quote for advanced consumers + } + + res.json(response) + } catch (error) { + console.error('Error in getQuote:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts new file mode 100644 index 00000000000..7d80cd89ba7 --- /dev/null +++ b/packages/public-api/src/routes/rates.ts @@ -0,0 +1,183 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { getAssetsById, getAsset } from '../assets' +import { DEFAULT_AFFILIATE_BPS } from '../config' +import { createServerSwapperDeps } from '../swapperDeps' +import type { ApiRate, RatesResponse, ErrorResponse } from '../types' + +// Request validation schema +const RatesRequestSchema = z.object({ + sellAssetId: z.string().min(1), + buyAssetId: z.string().min(1), + sellAmountCryptoBaseUnit: z.string().min(1), + slippageTolerancePercentageDecimal: z.string().optional(), + allowMultiHop: z.boolean().optional().default(true), +}) + +// Swapper names as strings to avoid import issues +const ENABLED_SWAPPER_NAMES = [ + 'THORChain', + 'MAYAChain', + '0x', + 'CoW Swap', + 'Portals', + 'Chainflip', + 'Jupiter', + 'Relay', + 'ButterSwap', + 'Bebop', +] as const + +// Rate timeout per swapper (10 seconds) +const RATE_TIMEOUT_MS = 10_000 + +// Lazy load swapper to avoid import issues at module load time +let swapperModule: typeof import('@shapeshiftoss/swapper') | null = null +const getSwapperModule = async () => { + if (!swapperModule) { + swapperModule = await import('@shapeshiftoss/swapper') + } + return swapperModule +} + +export const getRates = async (req: Request, res: Response): Promise => { + try { + // Parse and validate request + const parseResult = RatesRequestSchema.safeParse(req.query) + if (!parseResult.success) { + const errorResponse: ErrorResponse = { + error: 'Invalid request parameters', + details: parseResult.error.errors, + } + res.status(400).json(errorResponse) + return + } + + const { + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + slippageTolerancePercentageDecimal, + allowMultiHop, + } = parseResult.data + + // Get assets + const sellAsset = getAsset(sellAssetId) + const buyAsset = getAsset(buyAssetId) + + if (!sellAsset) { + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) + return + } + if (!buyAsset) { + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + return + } + + // Lazy load swapper module + const { getTradeRates, swappers, SwapperName } = await getSwapperModule() + + // Create swapper dependencies + const deps = createServerSwapperDeps(getAssetsById()) + + // Build rate input + const rateInput = { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmountCryptoBaseUnit, + affiliateBps: DEFAULT_AFFILIATE_BPS, + allowMultiHop, + slippageTolerancePercentageDecimal, + receiveAddress: undefined, + sendAddress: undefined, + accountNumber: undefined, + quoteOrRate: 'rate' as const, + chainId: sellAsset.chainId, + } + + // Map string names to SwapperName enum + const enabledSwappers = ENABLED_SWAPPER_NAMES.map(name => { + const swapperName = Object.values(SwapperName).find(v => v === name) + return swapperName + }).filter((name): name is typeof SwapperName[keyof typeof SwapperName] => name !== undefined) + + // Fetch rates from all enabled swappers in parallel + const ratePromises = enabledSwappers.map(async (swapperName): Promise => { + try { + const swapper = swappers[swapperName] + if (!swapper) return null + + const result = await getTradeRates(rateInput, swapperName, deps, RATE_TIMEOUT_MS) + + if (!result) return null + + if (result.isErr()) { + const error = result.unwrapErr() + return { + swapperName, + rate: '0', + buyAmountCryptoBaseUnit: '0', + sellAmountCryptoBaseUnit, + steps: 0, + estimatedExecutionTimeMs: undefined, + priceImpactPercentageDecimal: undefined, + affiliateBps: DEFAULT_AFFILIATE_BPS, + networkFeeCryptoBaseUnit: undefined, + error: { + code: error.code || ('UnknownError' as any), + message: error.message, + }, + } + } + + const rates = result.unwrap() + if (rates.length === 0) return null + + // Return the first/best rate + const rate = rates[0] + const firstStep = rate.steps[0] + + return { + swapperName, + rate: rate.rate, + buyAmountCryptoBaseUnit: firstStep.buyAmountAfterFeesCryptoBaseUnit, + sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + steps: rate.steps.length, + estimatedExecutionTimeMs: firstStep.estimatedExecutionTimeMs, + priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal, + affiliateBps: rate.affiliateBps, + networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, + } + } catch (error) { + console.error(`Error fetching rate from ${swapperName}:`, error) + return null + } + }) + + const results = await Promise.allSettled(ratePromises) + const rates: ApiRate[] = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value) + .filter((r): r is ApiRate => r !== null) + + // Sort by best rate (highest buy amount) + rates.sort((a, b) => { + if (a.error && !b.error) return 1 + if (!a.error && b.error) return -1 + return BigInt(b.buyAmountCryptoBaseUnit) > BigInt(a.buyAmountCryptoBaseUnit) ? 1 : -1 + }) + + const now = Date.now() + const response: RatesResponse = { + rates, + timestamp: now, + expiresAt: now + 30_000, // 30 second expiry + } + + res.json(response) + } catch (error) { + console.error('Error in getRates:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + } +} diff --git a/packages/public-api/src/server-standalone.ts b/packages/public-api/src/server-standalone.ts new file mode 100644 index 00000000000..add3cf4a17f --- /dev/null +++ b/packages/public-api/src/server-standalone.ts @@ -0,0 +1,250 @@ +/** + * Standalone test server for the Public Swap API + * This version uses mocked responses to demonstrate the API structure + * without requiring full swapper package integration. + * + * For production, use index.ts which integrates with the real swapper package. + */ + +import express from 'express' +import cors from 'cors' +import { v4 as uuidv4 } from 'uuid' +import crypto from 'crypto' + +const API_PORT = parseInt(process.env.PORT || '3001', 10) +const API_HOST = process.env.HOST || '0.0.0.0' + +// Static API keys for testing +const STATIC_API_KEYS: Record = { + 'test-api-key-123': { name: 'Test Partner', feeSharePercentage: 50 }, +} + +const app = express() + +// Middleware +app.use(cors()) +app.use(express.json()) + +// API key auth middleware +const apiKeyAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => { + const apiKey = req.header('X-API-Key') + + if (!apiKey) { + return res.status(401).json({ error: 'API key required', code: 'MISSING_API_KEY' }) + } + + const partnerInfo = STATIC_API_KEYS[apiKey] + if (!partnerInfo) { + return res.status(401).json({ error: 'Invalid API key', code: 'INVALID_API_KEY' }) + } + + ;(req as any).partner = { + id: crypto.createHash('sha256').update(apiKey).digest('hex').substring(0, 16), + name: partnerInfo.name, + feeSharePercentage: partnerInfo.feeSharePercentage, + } + + next() +} + +// Health check +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: Date.now() }) +}) + +// Mock rates endpoint +app.get('/v1/swap/rates', apiKeyAuth, (req, res) => { + const { sellAssetId, buyAssetId, sellAmountCryptoBaseUnit } = req.query + + if (!sellAssetId || !buyAssetId || !sellAmountCryptoBaseUnit) { + return res.status(400).json({ + error: 'Missing required parameters: sellAssetId, buyAssetId, sellAmountCryptoBaseUnit', + }) + } + + // Mock response demonstrating API structure + const mockRates = [ + { + swapperName: 'THORChain', + rate: '3500.123456', + buyAmountCryptoBaseUnit: '3500123456', + sellAmountCryptoBaseUnit: sellAmountCryptoBaseUnit as string, + steps: 1, + estimatedExecutionTimeMs: 600000, + priceImpactPercentageDecimal: '0.001', + affiliateBps: '55', + networkFeeCryptoBaseUnit: '5000000000000000', + }, + { + swapperName: '0x', + rate: '3498.789012', + buyAmountCryptoBaseUnit: '3498789012', + sellAmountCryptoBaseUnit: sellAmountCryptoBaseUnit as string, + steps: 1, + estimatedExecutionTimeMs: 30000, + priceImpactPercentageDecimal: '0.002', + affiliateBps: '55', + networkFeeCryptoBaseUnit: '3000000000000000', + }, + { + swapperName: 'CoW Swap', + rate: '3499.567890', + buyAmountCryptoBaseUnit: '3499567890', + sellAmountCryptoBaseUnit: sellAmountCryptoBaseUnit as string, + steps: 1, + estimatedExecutionTimeMs: 120000, + priceImpactPercentageDecimal: '0.0015', + affiliateBps: '55', + networkFeeCryptoBaseUnit: '2000000000000000', + }, + ] + + const now = Date.now() + res.json({ + rates: mockRates, + timestamp: now, + expiresAt: now + 30000, + }) +}) + +// Mock quote endpoint +app.post('/v1/swap/quote', apiKeyAuth, (req, res) => { + const { + sellAssetId, + buyAssetId, + sellAmountCryptoBaseUnit, + receiveAddress, + swapperName, + } = req.body + + if (!sellAssetId || !buyAssetId || !sellAmountCryptoBaseUnit || !receiveAddress || !swapperName) { + return res.status(400).json({ + error: 'Missing required parameters', + }) + } + + const quoteId = uuidv4() + const now = Date.now() + + // Mock response with transaction data + res.json({ + quoteId, + swapperName, + rate: '3500.123456', + sellAsset: { + assetId: sellAssetId, + chainId: 'eip155:1', + symbol: 'ETH', + name: 'Ethereum', + precision: 18, + }, + buyAsset: { + assetId: buyAssetId, + chainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + precision: 6, + }, + sellAmountCryptoBaseUnit, + buyAmountBeforeFeesCryptoBaseUnit: '3505000000', + buyAmountAfterFeesCryptoBaseUnit: '3500000000', + affiliateBps: '55', + slippageTolerancePercentageDecimal: '0.01', + networkFeeCryptoBaseUnit: '5000000000000000', + steps: [ + { + sellAsset: { assetId: sellAssetId, symbol: 'ETH', precision: 18 }, + buyAsset: { assetId: buyAssetId, symbol: 'USDC', precision: 6 }, + sellAmountCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit: '3500000000', + allowanceContract: '0x0000000000000000000000000000000000000000', + estimatedExecutionTimeMs: 600000, + source: swapperName, + transactionData: { + to: '0x1111111254EEB25477B68fb85Ed929f73A960582', + data: '0x12aa3caf000000000000000000000000...', // Truncated for example + value: sellAmountCryptoBaseUnit, + gasLimit: '250000', + }, + }, + ], + approval: { + isRequired: false, + spender: '0x0000000000000000000000000000000000000000', + }, + expiresAt: now + 60000, + }) +}) + +// Mock assets endpoint +app.get('/v1/assets', (_req, res) => { + res.json({ + assets: [ + { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + symbol: 'ETH', + name: 'Ethereum', + precision: 18, + }, + { + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + precision: 6, + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + chainId: 'bip122:000000000019d6689c085ae165831e93', + symbol: 'BTC', + name: 'Bitcoin', + precision: 8, + }, + ], + timestamp: Date.now(), + }) +}) + +// 404 handler +app.use((_req, res) => { + res.status(404).json({ error: 'Not found' }) +}) + +// Start server +app.listen(API_PORT, API_HOST, () => { + console.log(` +╔══════════════════════════════════════════════════════════════════╗ +β•‘ ShapeShift Public Swap API β•‘ +β•‘ (Mock/Test Server) β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +Server running at http://${API_HOST}:${API_PORT} + +Endpoints: + GET /health - Health check (no auth) + GET /v1/swap/rates - Get swap rates from all swappers + POST /v1/swap/quote - Get executable quote with tx data + GET /v1/assets - List supported assets + +Authentication: + Include 'X-API-Key' header for /v1/swap/* endpoints + Test key: test-api-key-123 + +Example requests: + + # Health check + curl http://localhost:${API_PORT}/health + + # Get rates + curl -H "X-API-Key: test-api-key-123" \\ + "http://localhost:${API_PORT}/v1/swap/rates?sellAssetId=eip155:1/slip44:60&buyAssetId=eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&sellAmountCryptoBaseUnit=1000000000000000000" + + # Get quote + curl -X POST -H "X-API-Key: test-api-key-123" -H "Content-Type: application/json" \\ + -d '{"sellAssetId":"eip155:1/slip44:60","buyAssetId":"eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","sellAmountCryptoBaseUnit":"1000000000000000000","receiveAddress":"0x742d35Cc6634C0532925a3b844Bc9e7595f4EdC3","swapperName":"THORChain"}' \\ + http://localhost:${API_PORT}/v1/swap/quote + +Note: This is a mock server for testing. Real integration uses index.ts. +`) +}) diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts new file mode 100644 index 00000000000..bd66890d583 --- /dev/null +++ b/packages/public-api/src/swapperDeps.ts @@ -0,0 +1,139 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import type { + ChainAdapter, + CosmosSdkChainAdapter, + EvmChainAdapter, + UtxoChainAdapter, + near, + solana, + starknet, + sui, + tron, +} from '@shapeshiftoss/chain-adapters' +import type { SwapperDeps } from '@shapeshiftoss/swapper' +import { KnownChainIds, type AssetsByIdPartial } from '@shapeshiftoss/types' + +import { getServerConfig } from './config' + +type GasFeeData = { + gasPrice: string + maxFeePerGas?: string + maxPriorityFeePerGas?: string +} + +type GasFeeDataEstimate = { + fast: GasFeeData + average: GasFeeData + slow: GasFeeData +} + +const EVM_UNCHAINED_URLS: Record = { + [KnownChainIds.EthereumMainnet]: 'https://api.ethereum.shapeshift.com', + [KnownChainIds.ArbitrumMainnet]: 'https://api.arbitrum.shapeshift.com', + [KnownChainIds.OptimismMainnet]: 'https://api.optimism.shapeshift.com', + [KnownChainIds.PolygonMainnet]: 'https://api.polygon.shapeshift.com', + [KnownChainIds.GnosisMainnet]: 'https://api.gnosis.shapeshift.com', + [KnownChainIds.AvalancheMainnet]: 'https://api.avalanche.shapeshift.com', + [KnownChainIds.BnbSmartChainMainnet]: 'https://api.bnbsmartchain.shapeshift.com', + [KnownChainIds.BaseMainnet]: 'https://api.base.shapeshift.com', + [KnownChainIds.ArbitrumNovaMainnet]: 'https://api.arbitrum-nova.shapeshift.com', +} + +const fetchGasFees = async (unchainedUrl: string): Promise => { + const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`) + if (!response.ok) { + throw new Error(`Failed to fetch gas fees: ${response.statusText}`) + } + const data = await response.json() + return { + fast: { + gasPrice: data.fast.gasPrice, + maxFeePerGas: data.fast.maxFeePerGas, + maxPriorityFeePerGas: data.fast.maxPriorityFeePerGas, + }, + average: { + gasPrice: data.average.gasPrice, + maxFeePerGas: data.average.maxFeePerGas, + maxPriorityFeePerGas: data.average.maxPriorityFeePerGas, + }, + slow: { + gasPrice: data.slow.gasPrice, + maxFeePerGas: data.slow.maxFeePerGas, + maxPriorityFeePerGas: data.slow.maxPriorityFeePerGas, + }, + } +} + +const createMinimalEvmAdapter = (chainId: ChainId) => { + const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + if (!unchainedUrl) { + throw new Error(`No Unchained URL configured for chain ${chainId}`) + } + + return { + getChainId: () => chainId, + getGasFeeData: () => fetchGasFees(unchainedUrl), + getFeeAssetId: () => { + switch (chainId) { + case KnownChainIds.EthereumMainnet: + return 'eip155:1/slip44:60' + case KnownChainIds.ArbitrumMainnet: + return 'eip155:42161/slip44:60' + case KnownChainIds.OptimismMainnet: + return 'eip155:10/slip44:60' + case KnownChainIds.PolygonMainnet: + return 'eip155:137/slip44:966' + case KnownChainIds.GnosisMainnet: + return 'eip155:100/slip44:700' + case KnownChainIds.AvalancheMainnet: + return 'eip155:43114/slip44:9005' + case KnownChainIds.BnbSmartChainMainnet: + return 'eip155:56/slip44:714' + case KnownChainIds.BaseMainnet: + return 'eip155:8453/slip44:60' + case KnownChainIds.ArbitrumNovaMainnet: + return 'eip155:42170/slip44:60' + default: + return `${chainId}/slip44:60` + } + }, + getDisplayName: () => chainId, + } +} + +const createStubAdapter = (type: string) => { + return () => { + throw new Error( + `Chain adapter ${type} not implemented in public API. ` + + `This swapper requires chain adapter functionality that is not yet available.` + ) + } +} + +export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperDeps => ({ + assetsById, + config: getServerConfig(), + mixPanel: undefined, + + assertGetChainAdapter: createStubAdapter('generic') as unknown as (chainId: ChainId) => ChainAdapter, + + assertGetEvmChainAdapter: ((chainId: ChainId) => { + const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + if (unchainedUrl) { + return createMinimalEvmAdapter(chainId) + } + throw new Error( + `Chain adapter EVM for ${chainId} not implemented in public API.` + ) + }) as unknown as (chainId: ChainId) => EvmChainAdapter, + + assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as (chainId: ChainId) => UtxoChainAdapter, + assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as (chainId: ChainId) => CosmosSdkChainAdapter, + assertGetSolanaChainAdapter: createStubAdapter('Solana') as unknown as (chainId: ChainId) => solana.ChainAdapter, + assertGetTronChainAdapter: createStubAdapter('Tron') as unknown as (chainId: ChainId) => tron.ChainAdapter, + assertGetSuiChainAdapter: createStubAdapter('Sui') as unknown as (chainId: ChainId) => sui.ChainAdapter, + assertGetNearChainAdapter: createStubAdapter('Near') as unknown as (chainId: ChainId) => near.ChainAdapter, + assertGetStarknetChainAdapter: createStubAdapter('Starknet') as unknown as (chainId: ChainId) => starknet.ChainAdapter, + + fetchIsSmartContractAddressQuery: async () => false, +}) diff --git a/packages/public-api/src/types.ts b/packages/public-api/src/types.ts new file mode 100644 index 00000000000..04b8dea9bf2 --- /dev/null +++ b/packages/public-api/src/types.ts @@ -0,0 +1,138 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import type { SwapperName, TradeQuote, TradeRate, TradeQuoteError } from '@shapeshiftoss/swapper' +import type { Asset } from '@shapeshiftoss/types' + +// Partner configuration +export type PartnerConfig = { + id: string + apiKeyHash: string + name: string + feeSharePercentage: number + status: 'active' | 'suspended' | 'pending' + rateLimit: { + requestsPerMinute: number + requestsPerDay: number + } + createdAt: Date +} + +// API Request Types +export type RatesRequest = { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmountCryptoBaseUnit: string + slippageTolerancePercentageDecimal?: string + allowMultiHop?: boolean +} + +export type QuoteRequest = { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmountCryptoBaseUnit: string + receiveAddress: string + sendAddress?: string + swapperName: SwapperName + slippageTolerancePercentageDecimal?: string + allowMultiHop?: boolean + accountNumber?: number +} + +export type StatusRequest = { + txHash: string + chainId: ChainId + swapperName: SwapperName +} + +// API Response Types +export type ApiRate = { + swapperName: SwapperName + rate: string + buyAmountCryptoBaseUnit: string + sellAmountCryptoBaseUnit: string + steps: number + estimatedExecutionTimeMs: number | undefined + priceImpactPercentageDecimal: string | undefined + affiliateBps: string + networkFeeCryptoBaseUnit: string | undefined + error?: { + code: TradeQuoteError + message: string + } +} + +export type RatesResponse = { + rates: ApiRate[] + timestamp: number + expiresAt: number +} + +export type ApiQuoteStep = { + sellAsset: Asset + buyAsset: Asset + sellAmountCryptoBaseUnit: string + buyAmountAfterFeesCryptoBaseUnit: string + allowanceContract: string + estimatedExecutionTimeMs: number | undefined + source: string + transactionData?: { + to: string + data: string + value: string + gasLimit?: string + } +} + +export type ApprovalInfo = { + isRequired: boolean + spender: string + approvalTx?: { + to: string + data: string + value: string + } +} + +export type QuoteResponse = { + quoteId: string + swapperName: SwapperName + rate: string + sellAsset: Asset + buyAsset: Asset + sellAmountCryptoBaseUnit: string + buyAmountBeforeFeesCryptoBaseUnit: string + buyAmountAfterFeesCryptoBaseUnit: string + affiliateBps: string + slippageTolerancePercentageDecimal: string | undefined + networkFeeCryptoBaseUnit: string | undefined + steps: ApiQuoteStep[] + approval: ApprovalInfo + expiresAt: number + quote: TradeQuote | TradeRate +} + +export type StatusResponse = { + status: 'pending' | 'confirmed' | 'failed' | 'unknown' + sellTxHash: string + buyTxHash?: string + message?: string +} + +export type AssetsResponse = { + assets: Asset[] + timestamp: number +} + +export type ErrorResponse = { + error: string + code?: string + details?: unknown +} + +// Extend Express Request to include partner info +declare global { + namespace Express { + interface Request { + partner?: PartnerConfig + } + } +} diff --git a/packages/public-api/tsconfig.json b/packages/public-api/tsconfig.json new file mode 100644 index 00000000000..34e59d9b831 --- /dev/null +++ b/packages/public-api/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/railway.public-api.toml b/railway.public-api.toml new file mode 100644 index 00000000000..89c10dea327 --- /dev/null +++ b/railway.public-api.toml @@ -0,0 +1,22 @@ +[build] +builder = "dockerfile" +dockerfilePath = "packages/public-api/Dockerfile" +watchPatterns = [ + "packages/public-api/**", + "packages/swapper/**", + "packages/caip/**", + "packages/types/**", + "packages/utils/**", + "packages/chain-adapters/**", + "packages/unchained-client/**", + "packages/contracts/**", + "packages/errors/**", + "public/generated/generatedAssetData.json" +] + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 60 +startCommand = "node server.cjs" +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 5 diff --git a/yarn.lock b/yarn.lock index 3093cb69180..27f3fa3062d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -197,6 +197,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/code-frame@npm:7.28.6" + dependencies: + "@babel/helper-validator-identifier": ^7.28.5 + js-tokens: ^4.0.0 + picocolors: ^1.1.1 + checksum: 6e98e47fd324b41c1919ff6d0fbf6fa5e991e5beff6b55803d9adaff9e11f4bc432803e52165f7b0d49af0f718209c3138a9b2fd51ff624b19d47704f11f8287 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.13.11, @babel/compat-data@npm:^7.17.7": version: 7.17.7 resolution: "@babel/compat-data@npm:7.17.7" @@ -232,6 +243,13 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/compat-data@npm:7.28.6" + checksum: 599b316aa0e3981aa9165ac34609ef5f29ebf5cecc04784e8b4932dd355aaa3599eaa222ff46a2fcfff52f083b8fd212650a52d8af57c4c217c81a100fefba09 + languageName: node + linkType: hard + "@babel/core@npm:^7.16.0, @babel/core@npm:^7.26.0": version: 7.26.9 resolution: "@babel/core@npm:7.26.9" @@ -324,7 +342,30 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": +"@babel/core@npm:^7.28.0": + version: 7.28.6 + resolution: "@babel/core@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/generator": ^7.28.6 + "@babel/helper-compilation-targets": ^7.28.6 + "@babel/helper-module-transforms": ^7.28.6 + "@babel/helpers": ^7.28.6 + "@babel/parser": ^7.28.6 + "@babel/template": ^7.28.6 + "@babel/traverse": ^7.28.6 + "@babel/types": ^7.28.6 + "@jridgewell/remapping": ^2.3.5 + convert-source-map: ^2.0.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.3 + semver: ^6.3.1 + checksum: 09d3712c52b2dba76dc0394127f6aacdbb575d79f8b6dc41230c1a13d8047d259ba06d88d56d62d95bb06c94c025c1e4bdd896929b5d4644ce0b96a84fd91553 + languageName: node + linkType: hard + +"@babel/core@npm:^7.28.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -447,6 +488,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/generator@npm:7.28.6" + dependencies: + "@babel/parser": ^7.28.6 + "@babel/types": ^7.28.6 + "@jridgewell/gen-mapping": ^0.3.12 + "@jridgewell/trace-mapping": ^0.3.28 + jsesc: ^3.0.2 + checksum: 74f62f140e301c8c21652f7db3bc275008708272c0395f178ba6953297af50c4ea484874a44b3f292d242ce8a977fd3f31d9d3a3501c3aaca9cd46e3b1cded01 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.16.7, @babel/helper-annotate-as-pure@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-annotate-as-pure@npm:7.18.6" @@ -564,6 +618,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": ^7.28.6 + "@babel/helper-validator-option": ^7.27.1 + browserslist: ^4.24.0 + lru-cache: ^5.1.1 + semver: ^6.3.1 + checksum: 8151e36b74eb1c5e414fe945c189436421f7bfa011884de5be3dd7fd77f12f1f733ff7c982581dfa0a49d8af724450243c2409427114b4a6cfeb8333259d001c + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6": version: 7.18.9 resolution: "@babel/helper-create-class-features-plugin@npm:7.18.9" @@ -882,6 +949,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 437513aa029898b588a38f7991d7656c539b22f595207d85d0c407240c9e3f2aff8b9d0d7115fdedc91e7fdce4465100549a052024e2fba6a810bcbb7584296b + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.22.15": version: 7.22.15 resolution: "@babel/helper-module-transforms@npm:7.22.15" @@ -953,6 +1030,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" + dependencies: + "@babel/helper-module-imports": ^7.28.6 + "@babel/helper-validator-identifier": ^7.28.5 + "@babel/traverse": ^7.28.6 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 522f7d1d08b5e2ccd4ec912aca879bd1506af78d1fb30f46e3e6b4bb69c6ae6ab4e379a879723844230d27dc6d04a55b03f5215cd3141b7a2b40bb4a02f71a9f + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" @@ -1354,6 +1444,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" + dependencies: + "@babel/template": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 4f3d555ec20dde40a2fcb244c86bfd9ec007b57ec9b30a9d04334c1ea2c1670bb82c151024124e1ab27ccf0b1f5ad30167633457a7c9ffbf4064fad2643f12fc + languageName: node + linkType: hard + "@babel/highlight@npm:^7.18.6": version: 7.18.6 resolution: "@babel/highlight@npm:7.18.6" @@ -1477,6 +1577,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/parser@npm:7.28.6" + dependencies: + "@babel/types": ^7.28.6 + bin: + parser: ./bin/babel-parser.js + checksum: 2a35319792ceef9bc918f0ff854449bef0120707798fe147ef988b0606de226e2fbc3a562ba687148bfe5336c6c67358fb27e71a94e425b28482dcaf0b172fd6 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9" @@ -3908,6 +4019,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/parser": ^7.28.6 + "@babel/types": ^7.28.6 + checksum: 8ab6383053e226025d9491a6e795293f2140482d14f60c1244bece6bf53610ed1e251d5e164de66adab765629881c7d9416e1e540c716541d2fd0f8f36a013d7 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.13.0, @babel/traverse@npm:^7.18.9": version: 7.19.1 resolution: "@babel/traverse@npm:7.19.1" @@ -4010,6 +4132,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/traverse@npm:7.28.6" + dependencies: + "@babel/code-frame": ^7.28.6 + "@babel/generator": ^7.28.6 + "@babel/helper-globals": ^7.28.0 + "@babel/parser": ^7.28.6 + "@babel/template": ^7.28.6 + "@babel/types": ^7.28.6 + debug: ^4.3.1 + checksum: 07bc23b720d111a20382fcdba776b800a7c1f94e35f8e4f417869f6769ba67c2b9573c8240924ca3b0ee5a88fa7ed048efb289e8b324f5cb4971e771174a0d32 + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.10, @babel/types@npm:^7.18.6, @babel/types@npm:^7.18.9, @babel/types@npm:^7.19.0, @babel/types@npm:^7.3.0, @babel/types@npm:^7.4.4": version: 7.19.0 resolution: "@babel/types@npm:7.19.0" @@ -4095,6 +4232,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/types@npm:7.28.6" + dependencies: + "@babel/helper-string-parser": ^7.27.1 + "@babel/helper-validator-identifier": ^7.28.5 + checksum: f76556cda59be337cc10dc68b2a9a947c10de018998bab41076e7b7e4489b28dd53299f98f22eec0774264c989515e6fdc56de91c73e3aa396367bb953200a6a + languageName: node + linkType: hard + "@bitcoinerlab/secp256k1@npm:^1.1.1": version: 1.1.1 resolution: "@bitcoinerlab/secp256k1@npm:1.1.1" @@ -5374,6 +5521,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-arm64@npm:0.21.5" @@ -5402,6 +5556,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-arm@npm:0.21.5" @@ -5430,6 +5591,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-x64@npm:0.21.5" @@ -5458,6 +5626,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/darwin-arm64@npm:0.21.5" @@ -5486,6 +5661,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/darwin-x64@npm:0.21.5" @@ -5514,6 +5696,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/freebsd-arm64@npm:0.21.5" @@ -5542,6 +5731,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/freebsd-x64@npm:0.21.5" @@ -5570,6 +5766,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-arm64@npm:0.21.5" @@ -5598,6 +5801,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-arm@npm:0.21.5" @@ -5626,6 +5836,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-ia32@npm:0.21.5" @@ -5654,6 +5871,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-loong64@npm:0.21.5" @@ -5682,6 +5906,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-mips64el@npm:0.21.5" @@ -5710,6 +5941,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-ppc64@npm:0.21.5" @@ -5738,6 +5976,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-riscv64@npm:0.21.5" @@ -5766,6 +6011,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-s390x@npm:0.21.5" @@ -5794,6 +6046,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-x64@npm:0.21.5" @@ -5822,6 +6081,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/netbsd-arm64@npm:0.24.2" @@ -5843,6 +6109,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/netbsd-x64@npm:0.21.5" @@ -5871,6 +6144,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/openbsd-arm64@npm:0.24.2" @@ -5892,6 +6172,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/openbsd-x64@npm:0.21.5" @@ -5920,6 +6207,20 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/sunos-x64@npm:0.21.5" @@ -5948,6 +6249,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-arm64@npm:0.21.5" @@ -5976,6 +6284,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-ia32@npm:0.21.5" @@ -6004,6 +6319,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-x64@npm:0.21.5" @@ -6032,6 +6354,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -12347,6 +12676,29 @@ __metadata: languageName: node linkType: hard +"@shapeshiftoss/public-api@workspace:packages/public-api": + version: 0.0.0-use.local + resolution: "@shapeshiftoss/public-api@workspace:packages/public-api" + dependencies: + "@shapeshiftoss/caip": "workspace:^" + "@shapeshiftoss/chain-adapters": "workspace:^" + "@shapeshiftoss/swapper": "workspace:^" + "@shapeshiftoss/types": "workspace:^" + "@shapeshiftoss/utils": "workspace:^" + "@sniptt/monads": ^0.5.10 + "@types/cors": ^2.8.17 + "@types/express": ^4.17.21 + "@types/node": ^22.10.4 + "@types/uuid": ^9.0.5 + cors: ^2.8.5 + express: ^4.21.0 + tsx: ^4.19.2 + typescript: ^5.7.3 + uuid: ^9.0.0 + zod: ^3.23.8 + languageName: unknown + linkType: soft + "@shapeshiftoss/swap-widget@workspace:packages/swap-widget": version: 0.0.0-use.local resolution: "@shapeshiftoss/swap-widget@workspace:packages/swap-widget" @@ -14591,6 +14943,16 @@ __metadata: languageName: node linkType: hard +"@types/body-parser@npm:*": + version: 1.19.6 + resolution: "@types/body-parser@npm:1.19.6" + dependencies: + "@types/connect": "*" + "@types/node": "*" + checksum: 33041e88eae00af2cfa0827e951e5f1751eafab2a8b6fce06cd89ef368a988907996436b1325180edaeddd1c0c7d0d0d4c20a6c9ff294a91e0039a9db9e9b658 + languageName: node + linkType: hard + "@types/bs58check@npm:^2.1.0": version: 2.1.0 resolution: "@types/bs58check@npm:2.1.0" @@ -14626,6 +14988,15 @@ __metadata: languageName: node linkType: hard +"@types/connect@npm:*": + version: 3.4.38 + resolution: "@types/connect@npm:3.4.38" + dependencies: + "@types/node": "*" + checksum: 7eb1bc5342a9604facd57598a6c62621e244822442976c443efb84ff745246b10d06e8b309b6e80130026a396f19bf6793b7cecd7380169f369dac3bfc46fb99 + languageName: node + linkType: hard + "@types/connect@npm:^3.4.33": version: 3.4.35 resolution: "@types/connect@npm:3.4.35" @@ -14642,6 +15013,15 @@ __metadata: languageName: node linkType: hard +"@types/cors@npm:^2.8.17": + version: 2.8.19 + resolution: "@types/cors@npm:2.8.19" + dependencies: + "@types/node": "*" + checksum: 9545cc532c9218754443f48a0c98c1a9ba4af1fe54a3425c95de75ff3158147bb39e666cb7c6bf98cc56a9c6dc7b4ce5b2cbdae6b55d5942e50c81b76ed6b825 + languageName: node + linkType: hard + "@types/css-font-loading-module@npm:0.0.7": version: 0.0.7 resolution: "@types/css-font-loading-module@npm:0.0.7" @@ -14822,6 +15202,30 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:^4.17.33": + version: 4.19.7 + resolution: "@types/express-serve-static-core@npm:4.19.7" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + "@types/send": "*" + checksum: 6d0f1126293a5b35d3697a5fc7e787d6c1bb8f5368dd0691fe0c8041a812a668ebb3b168124de30e600963d8acdf48ec7373daf7608d9dc1d6288f6c373d19a1 + languageName: node + linkType: hard + +"@types/express@npm:^4.17.21": + version: 4.17.25 + resolution: "@types/express@npm:4.17.25" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.33 + "@types/qs": "*" + "@types/serve-static": ^1 + checksum: 285d16008489d37b2be03e2e050bcf201d5d6ed9278ca13619d9029efd2055b192b2445f769116f716cfcf53d9d799a03f4e76199af9cea0ea3dee3d88595931 + languageName: node + linkType: hard + "@types/fast-text-encoding@npm:^1.0.1": version: 1.0.1 resolution: "@types/fast-text-encoding@npm:1.0.1" @@ -14866,6 +15270,13 @@ __metadata: languageName: node linkType: hard +"@types/http-errors@npm:*": + version: 2.0.5 + resolution: "@types/http-errors@npm:2.0.5" + checksum: a88da669366bc483e8f3b3eb3d34ada5f8d13eeeef851b1204d77e2ba6fc42aba4566d877cca5c095204a3f4349b87fe397e3e21288837bdd945dd514120755b + languageName: node + linkType: hard + "@types/http-proxy@npm:^1.17.8": version: 1.17.8 resolution: "@types/http-proxy@npm:1.17.8" @@ -14973,6 +15384,13 @@ __metadata: languageName: node linkType: hard +"@types/mime@npm:^1": + version: 1.3.5 + resolution: "@types/mime@npm:1.3.5" + checksum: e29a5f9c4776f5229d84e525b7cd7dd960b51c30a0fb9a028c0821790b82fca9f672dab56561e2acd9e8eed51d431bde52eafdfef30f643586c4162f1aecfc78 + languageName: node + linkType: hard + "@types/minimist@npm:^1.2.0": version: 1.2.2 resolution: "@types/minimist@npm:1.2.2" @@ -15084,6 +15502,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.10.4": + version: 22.19.3 + resolution: "@types/node@npm:22.19.3" + dependencies: + undici-types: ~6.21.0 + checksum: 2fffd870ac2a5a531a160034075c858e7c95c521b0612e9b7ae35e7a6bae1880c7a190a77a5c335fbb2e5f4a315c1e2f2b529f9be4bdbb2166664a7206e28951 + languageName: node + linkType: hard + "@types/node@npm:^22.13.14": version: 22.17.2 resolution: "@types/node@npm:22.17.2" @@ -15123,6 +15550,13 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:*": + version: 6.14.0 + resolution: "@types/qs@npm:6.14.0" + checksum: 1909205514d22b3cbc7c2314e2bd8056d5f05dfb21cf4377f0730ee5e338ea19957c41735d5e4806c746176563f50005bbab602d8358432e25d900bdf4970826 + languageName: node + linkType: hard + "@types/qs@npm:^6.9.18": version: 6.9.18 resolution: "@types/qs@npm:6.9.18" @@ -15130,6 +15564,13 @@ __metadata: languageName: node linkType: hard +"@types/range-parser@npm:*": + version: 1.2.7 + resolution: "@types/range-parser@npm:1.2.7" + checksum: 95640233b689dfbd85b8c6ee268812a732cf36d5affead89e806fe30da9a430767af8ef2cd661024fd97e19d61f3dec75af2df5e80ec3bea000019ab7028629a + languageName: node + linkType: hard + "@types/react-dom@npm:^19.0.0": version: 19.1.2 resolution: "@types/react-dom@npm:19.1.2" @@ -15282,6 +15723,36 @@ __metadata: languageName: node linkType: hard +"@types/send@npm:*": + version: 1.2.1 + resolution: "@types/send@npm:1.2.1" + dependencies: + "@types/node": "*" + checksum: 3b8388edeec77ae62f7bbc384c98ca06140614e4ef34fc04b35824f19937f472f8ff3785e83570e0d40e6d7c934c015d4831c82a74a1ade0d9676720835702c5 + languageName: node + linkType: hard + +"@types/send@npm:<1": + version: 0.17.6 + resolution: "@types/send@npm:0.17.6" + dependencies: + "@types/mime": ^1 + "@types/node": "*" + checksum: 5bd287f1357380963eb4b12daef5c8982f52a3269308ff3414304074d4ad7f05fe466f2cb476f54798096877ad3c5343692978776bd674b25261ecbeab87640f + languageName: node + linkType: hard + +"@types/serve-static@npm:^1": + version: 1.15.10 + resolution: "@types/serve-static@npm:1.15.10" + dependencies: + "@types/http-errors": "*" + "@types/node": "*" + "@types/send": <1 + checksum: f216eef2aaf2c8eff09f431c420c5c2989eaf0dfc15d106db9fb64c14577a4059af24fb0ae2eba7984d6360950c8cbc1fb52f65608106477729d251481bc96fe + languageName: node + linkType: hard + "@types/set-cookie-parser@npm:^2.4.0": version: 2.4.2 resolution: "@types/set-cookie-parser@npm:2.4.2" @@ -17468,6 +17939,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.8": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: ~2.1.34 + negotiator: 0.6.3 + checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -17934,6 +18415,13 @@ __metadata: languageName: node linkType: hard +"array-flatten@npm:1.1.1": + version: 1.1.1 + resolution: "array-flatten@npm:1.1.1" + checksum: a9925bf3512d9dce202112965de90c222cd59a4fbfce68a0951d25d965cf44642931f40aac72309c41f12df19afa010ecadceb07cfff9ccc1621e99d89ab5f3b + languageName: node + linkType: hard + "array-ify@npm:^1.0.0": version: 1.0.0 resolution: "array-ify@npm:1.0.0" @@ -19259,6 +19747,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:~1.20.3": + version: 1.20.4 + resolution: "body-parser@npm:1.20.4" + dependencies: + bytes: ~3.1.2 + content-type: ~1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: ~1.2.0 + http-errors: ~2.0.1 + iconv-lite: ~0.4.24 + on-finished: ~2.4.1 + qs: ~6.14.0 + raw-body: ~2.5.3 + type-is: ~1.6.18 + unpipe: ~1.0.0 + checksum: eaa212cff1737d2fbb49fc7aa1d71d9b456adea2dc3de388ff3c6d67b28028d6b1fa7e6cd77e3670b4cbd402ab011f80f6e5bb811480b53a28d11f33678c6298 + languageName: node + linkType: hard + "borsh@npm:1.0.0": version: 1.0.0 resolution: "borsh@npm:1.0.0" @@ -19735,6 +20243,13 @@ __metadata: languageName: node linkType: hard +"bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e + languageName: node + linkType: hard + "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -20589,6 +21104,22 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:~0.5.4": + version: 0.5.4 + resolution: "content-disposition@npm:0.5.4" + dependencies: + safe-buffer: 5.2.1 + checksum: afb9d545e296a5171d7574fcad634b2fdf698875f4006a9dd04a3e1333880c5c0c98d47b560d01216fb6505a54a2ba6a843ee3a02ec86d7e911e8315255f56c3 + languageName: node + linkType: hard + +"content-type@npm:~1.0.4, content-type@npm:~1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 + languageName: node + linkType: hard + "conventional-changelog-angular@npm:^5.0.11": version: 5.0.13 resolution: "conventional-changelog-angular@npm:5.0.13" @@ -20649,10 +21180,17 @@ __metadata: languageName: node linkType: hard -"cookie-es@npm:^1.2.2": - version: 1.2.2 - resolution: "cookie-es@npm:1.2.2" - checksum: 099050c30c967c89aa72d1d7984e87b3395f3e709cf148d297f436828ebfcc39033f5374d2efdc46d9b5e3eee50b1d59635432c252e57329fea7f09afeb4d055 +"cookie-es@npm:^1.0.0": + version: 1.0.0 + resolution: "cookie-es@npm:1.0.0" + checksum: e8721cf4d38f3e44049c9118874b323f4f674b1c5cef84a2b888f5bf86ad720ad17b51b43150cad7535a375c24e2921da603801ad28aa6125c3d36c031b41468 + languageName: node + linkType: hard + +"cookie-signature@npm:~1.0.6": + version: 1.0.7 + resolution: "cookie-signature@npm:1.0.7" + checksum: 1a62808cd30d15fb43b70e19829b64d04b0802d8ef00275b57d152de4ae6a3208ca05c197b6668d104c4d9de389e53ccc2d3bc6bcaaffd9602461417d8c40710 languageName: node linkType: hard @@ -20663,6 +21201,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:~0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + "cookiejar@npm:^2.1.1": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -20922,15 +21467,6 @@ __metadata: languageName: node linkType: hard -"crossws@npm:^0.3.5": - version: 0.3.5 - resolution: "crossws@npm:0.3.5" - dependencies: - uncrypto: ^0.1.3 - checksum: ca597f9b07d82c71fdeed033d6691c4ba2523c596089218ba2782fce26365c86d555e4ea9d6726d277a34087eac861b11bd4df30646dcfc1a1c867a174f2bf98 - languageName: node - linkType: hard - "crypto-browserify@npm:3.12.0, crypto-browserify@npm:^3.12.0": version: 3.12.0 resolution: "crypto-browserify@npm:3.12.0" @@ -21418,6 +21954,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:2.6.9, debug@npm:^2.2.0": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: 2.0.0 + checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -21430,15 +21975,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^2.2.0": - version: 2.6.9 - resolution: "debug@npm:2.6.9" - dependencies: - ms: 2.0.0 - checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6 - languageName: node - linkType: hard - "debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" @@ -21642,20 +22178,13 @@ __metadata: languageName: node linkType: hard -"defu@npm:^6.1.2": +"defu@npm:^6.1.2, defu@npm:^6.1.3": version: 6.1.3 resolution: "defu@npm:6.1.3" checksum: c857a0cf854632e8528dad36454fd1c812bff8f5f091d5a6892e75d7f6b76d8319afbbfb8c504daab84ac86e40037ff37c544d50f89ed5b5419ba1989a226777 languageName: node linkType: hard -"defu@npm:^6.1.4": - version: 6.1.4 - resolution: "defu@npm:6.1.4" - checksum: 40e3af6338f195ac1564f53d1887fa2d0429ac7e8c081204bc4d29191180059d3952b5f4e08fe5df8d59eb873aa26e9c88b56d4fac699673d4a372c93620b229 - languageName: node - linkType: hard - "delaunator@npm:5": version: 5.0.0 resolution: "delaunator@npm:5.0.0" @@ -21693,7 +22222,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -21740,10 +22269,10 @@ __metadata: languageName: node linkType: hard -"destr@npm:^2.0.5": - version: 2.0.5 - resolution: "destr@npm:2.0.5" - checksum: e6d5b9e922f528527cd98035249b4d34077828debd2be448a33e268ac1f803bd9a53e7cf0f5184ef68a67573b7f0a6033a89913f61eadaf0e180de49b148606e +"destroy@npm:1.2.0, destroy@npm:~1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38 languageName: node linkType: hard @@ -22079,6 +22608,13 @@ __metadata: languageName: node linkType: hard +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f + languageName: node + linkType: hard + "eip-712@npm:^1.0.0": version: 1.0.0 resolution: "eip-712@npm:1.0.0" @@ -22271,6 +22807,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -23188,6 +23731,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": 0.27.2 + "@esbuild/android-arm": 0.27.2 + "@esbuild/android-arm64": 0.27.2 + "@esbuild/android-x64": 0.27.2 + "@esbuild/darwin-arm64": 0.27.2 + "@esbuild/darwin-x64": 0.27.2 + "@esbuild/freebsd-arm64": 0.27.2 + "@esbuild/freebsd-x64": 0.27.2 + "@esbuild/linux-arm": 0.27.2 + "@esbuild/linux-arm64": 0.27.2 + "@esbuild/linux-ia32": 0.27.2 + "@esbuild/linux-loong64": 0.27.2 + "@esbuild/linux-mips64el": 0.27.2 + "@esbuild/linux-ppc64": 0.27.2 + "@esbuild/linux-riscv64": 0.27.2 + "@esbuild/linux-s390x": 0.27.2 + "@esbuild/linux-x64": 0.27.2 + "@esbuild/netbsd-arm64": 0.27.2 + "@esbuild/netbsd-x64": 0.27.2 + "@esbuild/openbsd-arm64": 0.27.2 + "@esbuild/openbsd-x64": 0.27.2 + "@esbuild/openharmony-arm64": 0.27.2 + "@esbuild/sunos-x64": 0.27.2 + "@esbuild/win32-arm64": 0.27.2 + "@esbuild/win32-ia32": 0.27.2 + "@esbuild/win32-x64": 0.27.2 + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 62ec92f8f40ad19922ae7d8dbf0427e41744120a77cc95abdf099dfb484d65fbe3c70cc55b8eccb7f6cb0d14e871ff1f2f76376d476915c2a6d2b800269261b2 + languageName: node + linkType: hard + "escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1" @@ -23202,6 +23834,13 @@ __metadata: languageName: node linkType: hard +"escape-html@npm:~1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 + languageName: node + linkType: hard + "escape-string-regexp@npm:2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -23629,6 +24268,13 @@ __metadata: languageName: node linkType: hard +"etag@npm:~1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff + languageName: node + linkType: hard + "eth-block-tracker@npm:6.1.0": version: 6.1.0 resolution: "eth-block-tracker@npm:6.1.0" @@ -24269,6 +24915,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.21.0": + version: 4.22.1 + resolution: "express@npm:4.22.1" + dependencies: + accepts: ~1.3.8 + array-flatten: 1.1.1 + body-parser: ~1.20.3 + content-disposition: ~0.5.4 + content-type: ~1.0.4 + cookie: ~0.7.1 + cookie-signature: ~1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + etag: ~1.8.1 + finalhandler: ~1.3.1 + fresh: ~0.5.2 + http-errors: ~2.0.0 + merge-descriptors: 1.0.3 + methods: ~1.1.2 + on-finished: ~2.4.1 + parseurl: ~1.3.3 + path-to-regexp: ~0.1.12 + proxy-addr: ~2.0.7 + qs: ~6.14.0 + range-parser: ~1.2.1 + safe-buffer: 5.2.1 + send: ~0.19.0 + serve-static: ~1.16.2 + setprototypeof: 1.2.0 + statuses: ~2.0.1 + type-is: ~1.6.18 + utils-merge: 1.0.1 + vary: ~1.1.2 + checksum: 38fd76585f6a2394e02d499f852fc70c94c9b1527bd5812eb5ee45c23b7f1297baaf13c55162253b14c1e36939b8401429d6594095e63d01ca77447dac72894e + languageName: node + linkType: hard + "ext@npm:^1.1.2": version: 1.7.0 resolution: "ext@npm:1.7.0" @@ -24584,6 +25269,21 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:~1.3.1": + version: 1.3.2 + resolution: "finalhandler@npm:1.3.2" + dependencies: + debug: 2.6.9 + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + on-finished: ~2.4.1 + parseurl: ~1.3.3 + statuses: ~2.0.2 + unpipe: ~1.0.0 + checksum: 4bce6b3e1f6998497a8ef8418bc307ef09daee05acc5a69a36da665565cbeb86218de1932e42dbf2eebf18f580053d2061eddbdeff9e312de45d46fbf4dd36ec + languageName: node + linkType: hard + "find-cache-dir@npm:^2.0.0": version: 2.1.0 resolution: "find-cache-dir@npm:2.1.0" @@ -24768,6 +25468,13 @@ __metadata: languageName: node linkType: hard +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: fd27e2394d8887ebd16a66ffc889dc983fbbd797d5d3f01087c020283c0f019a7d05ee85669383d8e0d216b116d720fc0cef2f6e9b7eb9f4c90c6e0bc7fd28e6 + languageName: node + linkType: hard + "framer-motion@npm:^12.6.5": version: 12.7.4 resolution: "framer-motion@npm:12.7.4" @@ -24799,6 +25506,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:~0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346 + languageName: node + linkType: hard + "friendly-challenge@npm:0.9.2": version: 0.9.2 resolution: "friendly-challenge@npm:0.9.2" @@ -25490,19 +26204,18 @@ __metadata: linkType: hard "h3@npm:^1.8.1, h3@npm:^1.8.2": - version: 1.15.5 - resolution: "h3@npm: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 + version: 1.9.0 + resolution: "h3@npm:1.9.0" + dependencies: + cookie-es: ^1.0.0 + defu: ^6.1.3 + destr: ^2.0.2 + iron-webcrypto: ^1.0.0 + radix3: ^1.1.0 + ufo: ^1.3.2 uncrypto: ^0.1.3 - checksum: 455f58681de2b789b39af45067db8dde35234c18d86ec5068687300aae3c0225f85edcd110906493e087407e2caaa1f292352769c484284e72558d7677c29899 + unenv: ^1.7.4 + checksum: 7305163b6fa4ecd7d14fce85e6372fae3580728957c8c021fa6b18176ce5e0cbfcc07c523bafcf7b34e895d2dab3ba731a11af43282faee4f2c8a331ec144f51 languageName: node linkType: hard @@ -25920,6 +26633,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -26050,7 +26776,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.4.24": +"iconv-lite@npm:^0.4.24, iconv-lite@npm:~0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" dependencies: @@ -26361,6 +27087,13 @@ __metadata: languageName: node linkType: hard +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: f88d3825981486f5a1942414c8d77dd6674dd71c065adcfa46f578d677edcb99fda25af42675cb59db492fdf427b34a5abfcde3982da11a8fd83a500b41cfe77 + languageName: node + linkType: hard + "ipfs-only-hash@npm:^4.0.0": version: 4.0.0 resolution: "ipfs-only-hash@npm:4.0.0" @@ -26420,10 +27153,10 @@ __metadata: languageName: node linkType: hard -"iron-webcrypto@npm:^1.2.1": - version: 1.2.1 - resolution: "iron-webcrypto@npm:1.2.1" - checksum: b158d1893c8d037c11a7dcfd1998b519f31f979643c2c505c6eb1170fd63553498a58b05947d5dea116975df8f12ede5ca235cb68e4c1f404fa6695e4508c60c +"iron-webcrypto@npm:^1.0.0": + version: 1.0.0 + resolution: "iron-webcrypto@npm:1.0.0" + checksum: bbd96cbbfec7d072296bc7464763b96555bdadb12aca50f1f1c7e4fcdc6acb102bc3488333e924f94404dd50eda24f84b67ad28323b9138ec7255a023e8dc19e languageName: node linkType: hard @@ -28485,6 +29218,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:0.3.0": + version: 0.3.0 + resolution: "media-typer@npm:0.3.0" + checksum: af1b38516c28ec95d6b0826f6c8f276c58aec391f76be42aa07646b4e39d317723e869700933ca6995b056db4b09a78c92d5440dc23657e6764be5d28874bba1 + languageName: node + linkType: hard + "memdown@npm:^1.0.0": version: 1.4.1 resolution: "memdown@npm:1.4.1" @@ -28561,6 +29301,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:1.0.3": + version: 1.0.3 + resolution: "merge-descriptors@npm:1.0.3" + checksum: 52117adbe0313d5defa771c9993fe081e2d2df9b840597e966aadafde04ae8d0e3da46bac7ca4efc37d4d2b839436582659cd49c6a43eacb3fe3050896a105d1 + languageName: node + linkType: hard + "merge-options@npm:^3.0.4": version: 3.0.4 resolution: "merge-options@npm:3.0.4" @@ -28627,6 +29374,13 @@ __metadata: languageName: node linkType: hard +"methods@npm:~1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a + languageName: node + linkType: hard + "micro-ftch@npm:^0.3.1": version: 0.3.1 resolution: "micro-ftch@npm:0.3.1" @@ -28683,7 +29437,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.0.1, mime-types@npm:^2.1.12, mime-types@npm:^2.1.34, mime-types@npm:~2.1.19": +"mime-types@npm:^2.0.1, mime-types@npm:^2.1.12, mime-types@npm:^2.1.34, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -28692,6 +29446,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557 + languageName: node + linkType: hard + "mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -29069,7 +29832,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -29367,7 +30130,7 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^0.6.3": +"negotiator@npm:0.6.3, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 @@ -29582,13 +30345,6 @@ __metadata: languageName: node linkType: hard -"node-mock-http@npm:^1.0.4": - version: 1.0.4 - resolution: "node-mock-http@npm:1.0.4" - checksum: 965f7915d3bbe9cbf5555dca12800cb0ca78a0b8db9c8f33a0ea6d9447fd200a925232b5ddf0d60c7112785ea282eb9baa079bb53d485b2b554355e8559f98ed - languageName: node - linkType: hard - "node-polyglot@npm:^2.4.0": version: 2.5.0 resolution: "node-polyglot@npm:2.5.0" @@ -29989,6 +30745,15 @@ __metadata: languageName: node linkType: hard +"on-finished@npm:~2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: 1.1.1 + checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -30440,6 +31205,13 @@ __metadata: languageName: node linkType: hard +"parseurl@npm:~1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 + languageName: node + linkType: hard + "pascal-case@npm:^3.1.2": version: 3.1.2 resolution: "pascal-case@npm:3.1.2" @@ -30544,6 +31316,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:~0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: ab237858bee7b25ecd885189f175ab5b5161e7b712b360d44f5c4516b8d271da3e4bf7bf0a7b9153ecb04c7d90ce8ff5158614e1208819cf62bac2b08452722e + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -31050,6 +31829,16 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:~2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + checksum: 29c6990ce9364648255454842f06f8c46fcd124d3e6d7c5066df44662de63cdc0bad032e9bf5a3d653ff72141cc7b6019873d685708ac8210c30458ad99f2b74 + languageName: node + linkType: hard + "proxy-compare@npm:2.5.1": version: 2.5.1 resolution: "proxy-compare@npm:2.5.1" @@ -31274,7 +32063,7 @@ pvutils@latest: languageName: node linkType: hard -"qs@npm:^6.14.0, qs@npm:^6.14.1": +"qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:~6.14.0": version: 6.14.1 resolution: "qs@npm:6.14.1" dependencies: @@ -31373,10 +32162,10 @@ pvutils@latest: languageName: node linkType: hard -"radix3@npm:^1.1.2": - version: 1.1.2 - resolution: "radix3@npm:1.1.2" - checksum: c4d49a3f603b5b7b7704dd907383c8884d12064d6d475f7ca8b05ecc7604d3bd73524b55e0fbcca0f7c9da3a2e9b473a6b4fbc0b639c29c2b0e85020ebda67d3 +"radix3@npm:^1.1.0": + version: 1.1.0 + resolution: "radix3@npm:1.1.0" + checksum: e5e6ed8fcf68be4d124bca4f7da7ba0fc7c5b6f9e98bc3f4424459c45d50f1f92506c5f7f8421b5cfee5823c524a4a2cef416053e88845813ce9fc9c7086729a languageName: node linkType: hard @@ -31399,6 +32188,25 @@ pvutils@latest: languageName: node linkType: hard +"range-parser@npm:~1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9 + languageName: node + linkType: hard + +"raw-body@npm:~2.5.3": + version: 2.5.3 + resolution: "raw-body@npm:2.5.3" + dependencies: + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.4.24 + unpipe: ~1.0.0 + checksum: 16aa51e504318ebeef7f84a4d884c0f273cb0b7f3f14ea88788f92f5f488870617c97d4f886e84f119f21a2d6cdda3c4554821f8b18ed6be0d731ecb5a063d2a + languageName: node + linkType: hard + "re-reselect@npm:^4.0.0": version: 4.0.0 resolution: "re-reselect@npm:4.0.0" @@ -33241,7 +34049,7 @@ pvutils@latest: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -33525,6 +34333,39 @@ pvutils@latest: languageName: node linkType: hard +"send@npm:~0.19.0, send@npm:~0.19.1": + version: 0.19.2 + resolution: "send@npm:0.19.2" + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + etag: ~1.8.1 + fresh: ~0.5.2 + http-errors: ~2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: ~2.4.1 + range-parser: ~1.2.1 + statuses: ~2.0.2 + checksum: f9e11b718b48dbea72daa6a80e36e5a00fb6d01b1a6cfda8b3135c9ca9db84257738283da23371f437148ccd8f400e6171cd2a3642fb43fda462da407d9d30c0 + languageName: node + linkType: hard + +"serve-static@npm:~1.16.2": + version: 1.16.3 + resolution: "serve-static@npm:1.16.3" + dependencies: + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + parseurl: ~1.3.3 + send: ~0.19.1 + checksum: ec7599540215e6676b223ea768bf7c256819180bf14f89d0b5d249a61bbb8f10b05b2a53048a153cb2cc7f3b367f1227d2fb715fe4b09d07299a9233eda1a453 + languageName: node + linkType: hard + "ses@npm:^0.18.7": version: 0.18.7 resolution: "ses@npm:0.18.7" @@ -33606,7 +34447,7 @@ pvutils@latest: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -34178,6 +35019,13 @@ pvutils@latest: languageName: node linkType: hard +"statuses@npm:~2.0.1, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "std-env@npm:^3.4.3": version: 3.6.0 resolution: "std-env@npm:3.6.0" @@ -35022,7 +35870,7 @@ pvutils@latest: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -35336,6 +36184,22 @@ pvutils@latest: languageName: node linkType: hard +"tsx@npm:^4.19.2": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: ~0.27.0 + fsevents: ~2.3.3 + get-tsconfig: ^4.7.5 + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 50c98e4b6e66d1c30f72925c8e5e7be1a02377574de7cd367d7e7a6d4af43ca8ff659f91c654e7628b25a5498015e32f090529b92c679b0342811e1cf682e8cf + languageName: node + linkType: hard + "tty-browserify@npm:0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" @@ -35454,6 +36318,16 @@ pvutils@latest: languageName: node linkType: hard +"type-is@npm:~1.6.18": + version: 1.6.18 + resolution: "type-is@npm:1.6.18" + dependencies: + media-typer: 0.3.0 + mime-types: ~2.1.24 + checksum: 2c8e47675d55f8b4e404bcf529abdf5036c537a04c2b20177bcf78c9e3c1da69da3942b1346e6edb09e823228c0ee656ef0e033765ec39a70d496ef601a0c657 + languageName: node + linkType: hard + "type@npm:^1.0.1": version: 1.2.0 resolution: "type@npm:1.2.0" @@ -35667,6 +36541,16 @@ pvutils@latest: languageName: node linkType: hard +"typescript@npm:^5.7.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f + languageName: node + linkType: hard + "typescript@patch:typescript@<4.8.0#~builtin, typescript@patch:typescript@^4.4.3#~builtin": version: 4.7.4 resolution: "typescript@patch:typescript@npm%3A4.7.4#~builtin::version=4.7.4&hash=65a307" @@ -35697,6 +36581,16 @@ pvutils@latest: languageName: node linkType: hard +"typescript@patch:typescript@^5.7.3#~builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=85af82" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497 + languageName: node + linkType: hard + "ua-is-frozen@npm:^0.1.2": version: 0.1.2 resolution: "ua-is-frozen@npm:0.1.2" @@ -35733,20 +36627,13 @@ pvutils@latest: languageName: node linkType: hard -"ufo@npm:^1.3.0, ufo@npm:^1.3.1": +"ufo@npm:^1.3.0, ufo@npm:^1.3.1, ufo@npm:^1.3.2": version: 1.3.2 resolution: "ufo@npm:1.3.2" checksum: f1180bb715ff4dd46152fd4dec41c731e84d7b9eaf1432548a0210b2f7e0cd29de125ac88e582c6a079d8ae5bc9ab04ef2bdbafe125086480b10c1006b81bfce languageName: node linkType: hard -"ufo@npm:^1.6.3": - version: 1.6.3 - resolution: "ufo@npm:1.6.3" - checksum: a23eff86bbbef0b9cc69c19c653c703b656c2328938576d3a60e05e246ef5a78d88b17c710afa146311c5b855950ccfee60ba8f6c8845e8d1ed6b5a9086ddad1 - languageName: node - linkType: hard - "uglify-js@npm:^3.1.4": version: 3.17.4 resolution: "uglify-js@npm:3.17.4" @@ -35856,6 +36743,19 @@ pvutils@latest: languageName: node linkType: hard +"unenv@npm:^1.7.4": + version: 1.8.0 + resolution: "unenv@npm:1.8.0" + dependencies: + consola: ^3.2.3 + defu: ^6.1.3 + mime: ^3.0.0 + node-fetch-native: ^1.4.1 + pathe: ^1.1.1 + checksum: 8118260ac7be8d13f99ebb5ca6a40203cf64cc592142753c1a1f75d40b07398c3208430d9a0299573cef3e4f2eba3bf6735901ff4217583df8344b2491314fb0 + languageName: node + linkType: hard + "unfetch@npm:^4.2.0": version: 4.2.0 resolution: "unfetch@npm:4.2.0" @@ -35947,6 +36847,13 @@ pvutils@latest: languageName: node linkType: hard +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 + languageName: node + linkType: hard + "unplugin@npm:2.1.0": version: 2.1.0 resolution: "unplugin@npm:2.1.0" @@ -36288,6 +37195,13 @@ pvutils@latest: languageName: node linkType: hard +"utils-merge@npm:1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080 + languageName: node + linkType: hard + "uuid@npm:8.3.2, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" @@ -36489,7 +37403,7 @@ pvutils@latest: languageName: node linkType: hard -"vary@npm:^1": +"vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b From b27a1a5d3898199e5883e11db0e91fd6a11e2cb0 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 12:47:44 +1100 Subject: [PATCH 02/46] fix(public-api): skip postinstall scripts in Docker build --- packages/public-api/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index 58801bd0420..0b8acf8e9c4 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -21,8 +21,8 @@ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ -# Install dependencies -RUN yarn install --immutable +# Install dependencies (skip postinstall scripts that require git/cypress/etc) +RUN YARN_ENABLE_SCRIPTS=false yarn install --immutable # Copy source files COPY packages/ ./packages/ From 2e59395eacfec2530925efaf2fde0f4abdc32468 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 13:02:40 +1100 Subject: [PATCH 03/46] fix(public-api): use --mode skip-build for yarn install in Docker - Replace YARN_ENABLE_SCRIPTS=false with --mode skip-build to properly skip all lifecycle scripts including postinstall that requires git - Add tsconfig files copy for TypeScript compilation - Add build:docker script to unchained-client to skip Java-based code generation - Use explicit workspace builds instead of yarn workspaces foreach --- packages/public-api/Dockerfile | 17 ++++++++++++++--- packages/unchained-client/package.json | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index 0b8acf8e9c4..144bc0aa558 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -21,15 +21,26 @@ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ -# Install dependencies (skip postinstall scripts that require git/cypress/etc) -RUN YARN_ENABLE_SCRIPTS=false yarn install --immutable +# Install dependencies (skip build/postinstall scripts that require git/cypress/etc) +RUN yarn install --immutable --mode skip-build # Copy source files COPY packages/ ./packages/ COPY public/generated/generatedAssetData.json ./public/generated/ +COPY tsconfig.json tsconfig.packages.json ./ # Build packages that public-api depends on -RUN yarn workspaces foreach -Rt --from @shapeshiftoss/public-api run build +# Use build:docker for unchained-client to skip Java-based code generation +# The generated code must exist locally before building Docker image +RUN yarn workspace @shapeshiftoss/errors build && \ + yarn workspace @shapeshiftoss/types build && \ + yarn workspace @shapeshiftoss/contracts build && \ + yarn workspace @shapeshiftoss/caip build && \ + yarn workspace @shapeshiftoss/utils build && \ + yarn workspace @shapeshiftoss/unchained-client build:docker && \ + yarn workspace @shapeshiftoss/chain-adapters build && \ + yarn workspace @shapeshiftoss/swapper build && \ + yarn workspace @shapeshiftoss/public-api build # Build the public-api bundle WORKDIR /app/packages/public-api diff --git a/packages/unchained-client/package.json b/packages/unchained-client/package.json index eaf3d095e72..f4b80194502 100644 --- a/packages/unchained-client/package.json +++ b/packages/unchained-client/package.json @@ -22,7 +22,9 @@ }, "scripts": { "build": "yarn clean && yarn generate && yarn run -T tsc --build && yarn postbuild", + "build:docker": "yarn clean:dist && yarn run -T tsc --build && yarn postbuild", "clean": "rm -rf dist src/generated", + "clean:dist": "rm -rf dist", "dev": "yarn run -T tsc --build --watch", "generate": "TS_POST_PROCESS_FILE=./generator/post_process.sh JAVA_OPTS='-Dlog.level=error' openapi-generator-cli generate", "postbuild": "yarn postbuild:esm && yarn postbuild:cjs", From 16d5d73dd13ad199888be4707eb31b1ba95ff266 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 13:06:50 +1100 Subject: [PATCH 04/46] fix(public-api): fix TypeScript compilation errors - Type JSON response in swapperDeps.ts to fix 'data is of type unknown' error - Add GetTradeRateInput type assertion in rates.ts for chainId compatibility - Add GetTradeQuoteInputWithWallet type assertion in quote.ts for chainId compatibility --- packages/public-api/src/routes/quote.ts | 4 ++-- packages/public-api/src/routes/rates.ts | 3 ++- packages/public-api/src/swapperDeps.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index 3c61ae75874..5e82522b8c1 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -1,7 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { v4 as uuidv4 } from 'uuid' -import type { TradeQuote, TradeQuoteStep, SwapperName } from '@shapeshiftoss/swapper' +import type { TradeQuote, TradeQuoteStep, SwapperName, GetTradeQuoteInputWithWallet } from '@shapeshiftoss/swapper' import { fromChainId } from '@shapeshiftoss/caip' import { getAssetsById, getAsset } from '../assets' @@ -200,7 +200,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { } // Fetch quote - const result = await getTradeQuotes(quoteInput, validSwapperName, deps) + const result = await getTradeQuotes(quoteInput as GetTradeQuoteInputWithWallet, validSwapperName, deps) if (!result) { res.status(404).json({ error: 'No quote available from this swapper' } as ErrorResponse) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 7d80cd89ba7..f5af33a8cc8 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -5,6 +5,7 @@ import { getAssetsById, getAsset } from '../assets' import { DEFAULT_AFFILIATE_BPS } from '../config' import { createServerSwapperDeps } from '../swapperDeps' import type { ApiRate, RatesResponse, ErrorResponse } from '../types' +import type { GetTradeRateInput } from '@shapeshiftoss/swapper' // Request validation schema const RatesRequestSchema = z.object({ @@ -108,7 +109,7 @@ export const getRates = async (req: Request, res: Response): Promise => { const swapper = swappers[swapperName] if (!swapper) return null - const result = await getTradeRates(rateInput, swapperName, deps, RATE_TIMEOUT_MS) + const result = await getTradeRates(rateInput as GetTradeRateInput, swapperName, deps, RATE_TIMEOUT_MS) if (!result) return null diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index bd66890d583..8cec37876a9 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -44,7 +44,7 @@ const fetchGasFees = async (unchainedUrl: string): Promise = if (!response.ok) { throw new Error(`Failed to fetch gas fees: ${response.statusText}`) } - const data = await response.json() + const data = (await response.json()) as GasFeeDataEstimate return { fast: { gasPrice: data.fast.gasPrice, From 43af3d1cc97805abb042dae824ce8bf593a43414 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 13:16:46 +1100 Subject: [PATCH 05/46] fix(public-api): add Java to Docker for unchained-client code generation - Install openjdk17-jre in build stage for OpenAPI generator CLI - Use full unchained-client build script instead of build:docker --- packages/public-api/Dockerfile | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index 144bc0aa558..b469adea9a6 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -3,8 +3,8 @@ FROM node:22-alpine AS builder WORKDIR /app -# Install build dependencies -RUN apk add --no-cache python3 make g++ +# Install build dependencies (including Java for unchained-client code generation) +RUN apk add --no-cache python3 make g++ openjdk17-jre # Copy workspace files COPY package.json yarn.lock .yarnrc.yml ./ @@ -30,14 +30,12 @@ COPY public/generated/generatedAssetData.json ./public/generated/ COPY tsconfig.json tsconfig.packages.json ./ # Build packages that public-api depends on -# Use build:docker for unchained-client to skip Java-based code generation -# The generated code must exist locally before building Docker image RUN yarn workspace @shapeshiftoss/errors build && \ yarn workspace @shapeshiftoss/types build && \ yarn workspace @shapeshiftoss/contracts build && \ yarn workspace @shapeshiftoss/caip build && \ yarn workspace @shapeshiftoss/utils build && \ - yarn workspace @shapeshiftoss/unchained-client build:docker && \ + yarn workspace @shapeshiftoss/unchained-client build && \ yarn workspace @shapeshiftoss/chain-adapters build && \ yarn workspace @shapeshiftoss/swapper build && \ yarn workspace @shapeshiftoss/public-api build From 0882a5657c1c6543c1e618773c13b0d41324896e Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 13:26:26 +1100 Subject: [PATCH 06/46] fix(unchained-client): add shebang and executable permission to post_process.sh Fixes 'Exec format error' during Docker build. --- packages/unchained-client/generator/post_process.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/unchained-client/generator/post_process.sh b/packages/unchained-client/generator/post_process.sh index e292c9080ad..67e53a0a7d3 100755 --- a/packages/unchained-client/generator/post_process.sh +++ b/packages/unchained-client/generator/post_process.sh @@ -1,3 +1,5 @@ +#!/bin/sh + # post process script to add // @ts-nocheck to the start of each generated file # there is a PR active to bake this into the generator that we can upgrade to when released: # https://github.com/OpenAPITools/openapi-generator/pull/11674 @@ -10,4 +12,4 @@ if [ "$(uname)" != "Darwin" ]; then else sed -i '' "$SED_COMMAND_1" $1 sed -i '' "$SED_COMMAND_2" $1 -fi \ No newline at end of file +fi From 28059a7dc778184306afd1614fca6852bd021cf9 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 13:39:09 +1100 Subject: [PATCH 07/46] fix(public-api): copy node_modules to runner stage Fixes runtime module not found errors for external dependencies like @cowprotocol/app-data --- packages/public-api/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index b469adea9a6..d0d009558a8 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -52,6 +52,7 @@ WORKDIR /app # Copy the bundled server and asset data COPY --from=builder /app/packages/public-api/dist/server.cjs ./server.cjs COPY --from=builder /app/public/generated/generatedAssetData.json ./public/generated/ +COPY --from=builder /app/node_modules ./node_modules # Set environment ENV NODE_ENV=production From 0e4ac6fe0742e8b680e3bcfbfb5467bf83319a2e Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 14:57:10 +1100 Subject: [PATCH 08/46] feat(public-api): add Scalar docs with auto-expanded sections and prefilled examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Scalar API reference UI at /docs endpoint - Configure defaultOpenAllTags to auto-expand all sections on page load - Prefill test API key (test-api-key-123) in authentication section - Add OpenAPI example values to all request schemas for prefilled forms - Set up zod-to-openapi for generating OpenAPI spec from Zod schemas πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/docs/openapi.ts | 238 +++++++++++++++++++++++ packages/public-api/src/index.ts | 3 + packages/public-api/src/routes/assets.ts | 12 +- packages/public-api/src/routes/docs.ts | 40 ++++ packages/public-api/src/routes/quote.ts | 20 +- packages/public-api/src/routes/rates.ts | 12 +- packages/public-api/src/setupZod.ts | 4 + 7 files changed, 307 insertions(+), 22 deletions(-) create mode 100644 packages/public-api/src/docs/openapi.ts create mode 100644 packages/public-api/src/routes/docs.ts create mode 100644 packages/public-api/src/setupZod.ts diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts new file mode 100644 index 00000000000..f65c330e7e6 --- /dev/null +++ b/packages/public-api/src/docs/openapi.ts @@ -0,0 +1,238 @@ +import '../setupZod' +import { + OpenAPIRegistry, + OpenApiGeneratorV3, +} from '@asteasolutions/zod-to-openapi' +import { z } from 'zod' + +import { AssetRequestSchema, AssetsListRequestSchema } from '../routes/assets' +import { QuoteRequestSchema } from '../routes/quote' +import { RatesRequestSchema } from '../routes/rates' + +export const registry = new OpenAPIRegistry() + +// Register reusable schemas +// We should probably define the response schemas with Zod too, but for now we'll do best effort with the request schemas +// and basic response structures. + +// Security Schemes +registry.registerComponent('securitySchemes', 'apiKeyAuth', { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', +}) + +// --- Definitions --- + +// Asset +const AssetSchema = registry.register( + 'Asset', + z.object({ + assetId: z.string().openapi({ example: 'eip155:1/slip44:60' }), + chainId: z.string().openapi({ example: 'eip155:1' }), + name: z.string().openapi({ example: 'Ethereum' }), + symbol: z.string().openapi({ example: 'ETH' }), + precision: z.number().openapi({ example: 18 }), + color: z.string().openapi({ example: '#5C6BC0' }), + icon: z.string().openapi({ example: 'https://assets.coincap.io/assets/icons/eth@2x.png' }), + explorer: z.string().openapi({ example: 'https://etherscan.io' }), + explorerAddressLink: z.string().openapi({ example: 'https://etherscan.io/address/' }), + explorerTxLink: z.string().openapi({ example: 'https://etherscan.io/tx/' }), + }) +) + +// Quote Response Step +const QuoteStepSchema = registry.register( + 'QuoteStep', + z.object({ + sellAsset: AssetSchema, + buyAsset: AssetSchema, + sellAmountCryptoBaseUnit: z.string().openapi({ example: '1000000000000000000' }), + buyAmountAfterFeesCryptoBaseUnit: z.string().openapi({ example: '995000000' }), + allowanceContract: z.string().openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }), + estimatedExecutionTimeMs: z.number().optional().openapi({ example: 60000 }), + source: z.string().openapi({ example: 'Thorchain' }), + transactionData: z + .object({ + to: z.string(), + data: z.string(), + value: z.string(), + gasLimit: z.string().optional(), + }) + .optional(), + }) +) + +// Quote Response +const QuoteResponseSchema = registry.register( + 'QuoteResponse', + z.object({ + quoteId: z.string().uuid(), + swapperName: z.string().openapi({ example: 'Thorchain' }), + rate: z.string().openapi({ example: '0.995' }), + sellAsset: AssetSchema, + buyAsset: AssetSchema, + sellAmountCryptoBaseUnit: z.string(), + buyAmountBeforeFeesCryptoBaseUnit: z.string(), + buyAmountAfterFeesCryptoBaseUnit: z.string(), + affiliateBps: z.string().openapi({ example: '10' }), + slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + steps: z.array(QuoteStepSchema), + expiresAt: z.number(), + }) +) + +// Rate Response +const RateResponseSchema = registry.register( + 'RateResponse', + z.object({ + rates: z.array( + z.object({ + swapperName: z.string(), + rate: z.string(), + buyAmountCryptoBaseUnit: z.string(), + sellAmountCryptoBaseUnit: z.string(), + steps: z.number(), + estimatedExecutionTimeMs: z.number().optional(), + priceImpactPercentageDecimal: z.string().optional(), + error: z + .object({ + code: z.string(), + message: z.string(), + }) + .optional(), + }) + ), + timestamp: z.number(), + expiresAt: z.number(), + }) +) + +// --- Paths --- + +// GET /v1/assets +registry.registerPath({ + method: 'get', + path: '/v1/assets', + summary: 'List supported assets', + description: 'Get a list of all supported assets, optionally filtered by chain.', + tags: ['Assets'], + request: { + query: AssetsListRequestSchema, + }, + responses: { + 200: { + description: 'List of assets', + content: { + 'application/json': { + schema: z.object({ + assets: z.array(AssetSchema), + timestamp: z.number(), + }), + }, + }, + }, + }, +}) + +// GET /v1/assets/{assetId} +registry.registerPath({ + method: 'get', + path: '/v1/assets/{assetId}', + summary: 'Get asset by ID', + description: 'Get details of a specific asset by its ID (URL encoded).', + tags: ['Assets'], + request: { + params: AssetRequestSchema, + }, + responses: { + 200: { + description: 'Asset details', + content: { + 'application/json': { + schema: AssetSchema, + }, + }, + }, + 404: { + description: 'Asset not found', + }, + }, +}) + +// GET /v1/swap/rates +registry.registerPath({ + method: 'get', + path: '/v1/swap/rates', + summary: 'Get swap rates', + description: + 'Get informative swap rates from all available swappers. This does not create a transaction.', + tags: ['Swaps'], + security: [{ apiKeyAuth: [] }], + request: { + query: RatesRequestSchema, + }, + responses: { + 200: { + description: 'Swap rates', + content: { + 'application/json': { + schema: RateResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request', + }, + }, +}) + +// POST /v1/swap/quote +registry.registerPath({ + method: 'post', + path: '/v1/swap/quote', + summary: 'Get executable quote', + description: + 'Get an executable quote for a swap, including transaction data. Requires a specific swapper name.', + tags: ['Swaps'], + security: [{ apiKeyAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: QuoteRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Swap quote', + content: { + 'application/json': { + schema: QuoteResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request or unavailable swapper', + }, + }, +}) + +export const generateOpenApiDocument = () => { + const generator = new OpenApiGeneratorV3(registry.definitions) + + return generator.generateDocument({ + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'ShapeShift Public API', + description: 'Public API for ShapeShift swap functionality', + }, + servers: [ + { url: 'https://api.shapeshift.com' }, + { url: 'http://localhost:3001' }, + ], + }) +} diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts index 3d74e3c8e20..e3f335d2a7f 100644 --- a/packages/public-api/src/index.ts +++ b/packages/public-api/src/index.ts @@ -1,3 +1,4 @@ +import './setupZod' import express from 'express' import cors from 'cors' @@ -7,6 +8,7 @@ import { apiKeyAuth, optionalApiKeyAuth } from './middleware/auth' import { getRates } from './routes/rates' import { getQuote } from './routes/quote' import { getAssets, getAssetById, getAssetCount } from './routes/assets' +import { docsRouter } from './routes/docs' const app = express() @@ -32,6 +34,7 @@ v1Router.get('/assets/count', optionalApiKeyAuth, getAssetCount) v1Router.get('/assets/:assetId(*)', optionalApiKeyAuth, getAssetById) app.use('/v1', v1Router) +app.use('/docs', docsRouter) // 404 handler app.use((_req, res) => { diff --git a/packages/public-api/src/routes/assets.ts b/packages/public-api/src/routes/assets.ts index d6214d7e6d5..a51ab9a3ce6 100644 --- a/packages/public-api/src/routes/assets.ts +++ b/packages/public-api/src/routes/assets.ts @@ -5,15 +5,15 @@ import { getAllAssets, getAsset, getAssetIds } from '../assets' import type { AssetsResponse, ErrorResponse } from '../types' // Request validation schema for single asset -const AssetRequestSchema = z.object({ - assetId: z.string().min(1), +export const AssetRequestSchema = z.object({ + assetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), }) // Request validation schema for filtered list -const AssetsListRequestSchema = z.object({ - chainId: z.string().optional(), - limit: z.coerce.number().min(1).max(1000).optional().default(100), - offset: z.coerce.number().min(0).optional().default(0), +export const AssetsListRequestSchema = z.object({ + chainId: z.string().optional().openapi({ example: 'eip155:1' }), + limit: z.coerce.number().min(1).max(1000).optional().default(100).openapi({ example: 100 }), + offset: z.coerce.number().min(0).optional().default(0).openapi({ example: 0 }), }) export const getAssets = async (req: Request, res: Response): Promise => { diff --git a/packages/public-api/src/routes/docs.ts b/packages/public-api/src/routes/docs.ts new file mode 100644 index 00000000000..f033a42ba51 --- /dev/null +++ b/packages/public-api/src/routes/docs.ts @@ -0,0 +1,40 @@ +import { apiReference } from '@scalar/express-api-reference' +import express from 'express' + +import { generateOpenApiDocument } from '../docs/openapi' + +const router = express.Router() + +// Generate Spec +const openApiDocument = generateOpenApiDocument() + +// Serve raw JSON spec +router.get('/json', (_req, res) => { + res.json(openApiDocument) +}) + +// Serve Scalar UI +router.use( + '/', + apiReference({ + spec: { + content: openApiDocument, + }, + theme: 'purple', + showSidebar: true, + hideDownloadButton: true, + darkMode: true, + defaultOpenAllTags: true, + authentication: { + preferredSecurityScheme: 'apiKeyAuth', + apiKey: { + token: 'test-api-key-123', + }, + }, + customCss: ` + .sidebar { --theme-color-1: #383838; } + `, + } as any) +) + +export const docsRouter = router diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index 5e82522b8c1..3f36f785974 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -19,16 +19,16 @@ const getSwapperModule = async () => { } // Request validation schema - swapperName is string, validated later -const QuoteRequestSchema = z.object({ - sellAssetId: z.string().min(1), - buyAssetId: z.string().min(1), - sellAmountCryptoBaseUnit: z.string().min(1), - receiveAddress: z.string().min(1), - sendAddress: z.string().optional(), - swapperName: z.string().min(1), - slippageTolerancePercentageDecimal: z.string().optional(), - allowMultiHop: z.boolean().optional().default(true), - accountNumber: z.number().optional().default(0), +export const QuoteRequestSchema = z.object({ + sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), + buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), + sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), + receiveAddress: z.string().min(1).openapi({ example: '0x0000000000000000000000000000000000000000' }), + sendAddress: z.string().optional().openapi({ example: '0x0000000000000000000000000000000000000000' }), + swapperName: z.string().min(1).openapi({ example: 'THORChain' }), + slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), + accountNumber: z.number().optional().default(0).openapi({ example: 0 }), }) // Helper to extract transaction data from quote step diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index f5af33a8cc8..1a81538a7c6 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -8,12 +8,12 @@ import type { ApiRate, RatesResponse, ErrorResponse } from '../types' import type { GetTradeRateInput } from '@shapeshiftoss/swapper' // Request validation schema -const RatesRequestSchema = z.object({ - sellAssetId: z.string().min(1), - buyAssetId: z.string().min(1), - sellAmountCryptoBaseUnit: z.string().min(1), - slippageTolerancePercentageDecimal: z.string().optional(), - allowMultiHop: z.boolean().optional().default(true), +export const RatesRequestSchema = z.object({ + sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), + buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), + sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), + slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), }) // Swapper names as strings to avoid import issues diff --git a/packages/public-api/src/setupZod.ts b/packages/public-api/src/setupZod.ts new file mode 100644 index 00000000000..ecef04a8792 --- /dev/null +++ b/packages/public-api/src/setupZod.ts @@ -0,0 +1,4 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi' +import { z } from 'zod' + +extendZodWithOpenApi(z) From ad3e6565f237828f138e9801217dbe1e6cbb06ea Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:13:11 +1100 Subject: [PATCH 09/46] fix(public-api): downgrade zod-to-openapi to v7 for zod v3 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zod-to-openapi v8.x requires zod v4 as a peer dependency, causing runtime errors when used with zod v3.23.8. Downgrade to v7.3.4 which supports zod v3. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/package.json | 6 +- yarn.lock | 163 +++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/public-api/package.json b/packages/public-api/package.json index e02a46806c8..8afd504ef0d 100644 --- a/packages/public-api/package.json +++ b/packages/public-api/package.json @@ -18,16 +18,20 @@ "docker:run": "docker run -p 3001:3001 --env-file .env shapeshift-public-api" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", + "@scalar/express-api-reference": "^0.8.30", "@shapeshiftoss/caip": "workspace:^", "@shapeshiftoss/chain-adapters": "workspace:^", "@shapeshiftoss/swapper": "workspace:^", "@shapeshiftoss/types": "workspace:^", "@shapeshiftoss/utils": "workspace:^", "@sniptt/monads": "^0.5.10", + "@types/swagger-ui-express": "^4.1.8", "cors": "^2.8.5", "express": "^4.21.0", "uuid": "^9.0.0", - "zod": "^3.23.8" + "yaml": "^2.8.2", + "zod": "3.23.8" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/yarn.lock b/yarn.lock index 27f3fa3062d..d5ed4ddb0b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -95,6 +95,17 @@ __metadata: languageName: node linkType: hard +"@asteasolutions/zod-to-openapi@npm:^7.3.4": + version: 7.3.4 + resolution: "@asteasolutions/zod-to-openapi@npm:7.3.4" + dependencies: + openapi3-ts: ^4.1.2 + peerDependencies: + zod: ^3.20.2 + checksum: 897568b35a5a317c91cf9419d49d9ce8ab1eb8e97253b0d11149032f62a5f692b27abefe7a081717225ac5b49b14df275ae691219a09605a8c347949375a1554 + languageName: node + linkType: hard + "@avnu/avnu-sdk@npm:^4.0.1": version: 4.0.1 resolution: "@avnu/avnu-sdk@npm:4.0.1" @@ -11735,6 +11746,43 @@ __metadata: languageName: node linkType: hard +"@scalar/core@npm:0.3.28": + version: 0.3.28 + resolution: "@scalar/core@npm:0.3.28" + dependencies: + "@scalar/types": 0.5.4 + checksum: e585d454d09c8b12ddc196eaa20a23c55ac78de3590455d2fc289cf87d036d028af2de547cf92a9e3f41fa9497be8b568de42be8789f334576905ea6b7baf882 + languageName: node + linkType: hard + +"@scalar/express-api-reference@npm:^0.8.30": + version: 0.8.30 + resolution: "@scalar/express-api-reference@npm:0.8.30" + dependencies: + "@scalar/core": 0.3.28 + checksum: ae0fdded4fb85878bfdffa264511c4f49a1444d407353f8a53a7aba016df405d4acabbeac070f7b750c13d3a4096b4755d001af1a8f7949256f0a49bf7f9568a + languageName: node + linkType: hard + +"@scalar/helpers@npm:0.2.4": + version: 0.2.4 + resolution: "@scalar/helpers@npm:0.2.4" + checksum: 3b1ac375ce74536ab9d650257a3a2d6514a9be8e1c8158a12bf46b06ed4299b57fb90631155ded753460c9fbbcdba1a65ad0b2d743d5dc937f49bebcadcf58dc + languageName: node + linkType: hard + +"@scalar/types@npm:0.5.4": + version: 0.5.4 + resolution: "@scalar/types@npm:0.5.4" + dependencies: + "@scalar/helpers": 0.2.4 + nanoid: 5.1.5 + type-fest: 5.0.0 + zod: ^4.1.11 + checksum: 91263d099ac4460bb7f71dff54b561f2f7195157f467dafb015f78cbde01666767e1b3430ae40e1c1f1a4d5025aa0bc8d6734e1cd6bb45636303b8996a78a241 + languageName: node + linkType: hard + "@scure/base@npm:1.2.6, @scure/base@npm:^1.2.4, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.1": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" @@ -12680,6 +12728,8 @@ __metadata: version: 0.0.0-use.local resolution: "@shapeshiftoss/public-api@workspace:packages/public-api" dependencies: + "@asteasolutions/zod-to-openapi": ^7.3.4 + "@scalar/express-api-reference": ^0.8.30 "@shapeshiftoss/caip": "workspace:^" "@shapeshiftoss/chain-adapters": "workspace:^" "@shapeshiftoss/swapper": "workspace:^" @@ -12689,13 +12739,15 @@ __metadata: "@types/cors": ^2.8.17 "@types/express": ^4.17.21 "@types/node": ^22.10.4 + "@types/swagger-ui-express": ^4.1.8 "@types/uuid": ^9.0.5 cors: ^2.8.5 express: ^4.21.0 tsx: ^4.19.2 typescript: ^5.7.3 uuid: ^9.0.0 - zod: ^3.23.8 + yaml: ^2.8.2 + zod: 3.23.8 languageName: unknown linkType: soft @@ -15214,6 +15266,29 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:^5.0.0": + version: 5.1.0 + resolution: "@types/express-serve-static-core@npm:5.1.0" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + "@types/send": "*" + checksum: a2a780a9954e4553b69474ea76ab5c26534dfb274e1a49524f6394cbb590c2bbf73ce9dc67ab920c25d91583c8a99d8e486696b6e9810bef7a964fcbaad88a7b + languageName: node + linkType: hard + +"@types/express@npm:*": + version: 5.0.6 + resolution: "@types/express@npm:5.0.6" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^5.0.0 + "@types/serve-static": ^2 + checksum: da2cc3de1b1a4d7f20ed3fb6f0a8ee08e99feb3c2eb5a8d643db77017d8d0e70fee9e95da38a73f51bcdf5eda3bb6435073c0271dc04fb16fda92e55daf911fa + languageName: node + linkType: hard + "@types/express@npm:^4.17.21": version: 4.17.25 resolution: "@types/express@npm:4.17.25" @@ -15742,6 +15817,16 @@ __metadata: languageName: node linkType: hard +"@types/serve-static@npm:*, @types/serve-static@npm:^2": + version: 2.2.0 + resolution: "@types/serve-static@npm:2.2.0" + dependencies: + "@types/http-errors": "*" + "@types/node": "*" + checksum: 0ad152ae2851cbe6c9381d0eca5fff8e6ff56afc0e03099efa88712c00318f52d8b01000be391375dcb2dbd912a54dacfc67ff36bae636a61570d74feba559b7 + languageName: node + linkType: hard + "@types/serve-static@npm:^1": version: 1.15.10 resolution: "@types/serve-static@npm:1.15.10" @@ -15778,6 +15863,16 @@ __metadata: languageName: node linkType: hard +"@types/swagger-ui-express@npm:^4.1.8": + version: 4.1.8 + resolution: "@types/swagger-ui-express@npm:4.1.8" + dependencies: + "@types/express": "*" + "@types/serve-static": "*" + checksum: bf4d84fb21c11e820238685cdc102f8d5e82d4382df9450a358bf80f70f5f0699c87645a291c52beee1eba5c0ee594e03714b236f1a0737272941a8708218f8f + languageName: node + linkType: hard + "@types/through@npm:*": version: 0.0.30 resolution: "@types/through@npm:0.0.30" @@ -29638,10 +29733,10 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0": - version: 5.0.0 - resolution: "minipass@npm:5.0.0" - checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea +"minipass@npm:^4.0.0": + version: 4.2.5 + resolution: "minipass@npm:4.2.5" + checksum: 4f9c19af23a5d4a9e7156feefc9110634b178a8cff8f8271af16ec5ebf7e221725a97429952c856f5b17b30c2065ebd24c81722d90c93d2122611d75b952b48f languageName: node linkType: hard @@ -29995,6 +30090,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:5.1.5": + version: 5.1.5 + resolution: "nanoid@npm:5.1.5" + bin: + nanoid: bin/nanoid.js + checksum: 6de2d006b51c983be385ef7ee285f7f2a57bd96f8c0ca881c4111461644bd81fafc2544f8e07cb834ca0f3e0f3f676c1fe78052183f008b0809efe6e273119f5 + languageName: node + linkType: hard + "nanoid@npm:^3.1.31, nanoid@npm:^3.3.6": version: 3.3.6 resolution: "nanoid@npm:3.3.6" @@ -30814,6 +30918,15 @@ __metadata: languageName: node linkType: hard +"openapi3-ts@npm:^4.1.2": + version: 4.5.0 + resolution: "openapi3-ts@npm:4.5.0" + dependencies: + yaml: ^2.8.0 + checksum: 3a8609c8b00aed06dbc2fb8d203f86405ea3e45d7988432d2d0e4db7aa81b85ef3136d0acfaa7fcd47dadbf749d0e9f14d1b2f6ec79c9b2d174b02489f788aac + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -35531,16 +35644,16 @@ pvutils@latest: linkType: hard "tar@npm:^6.1.11, tar@npm:^6.1.2": - version: 6.2.1 - resolution: "tar@npm:6.2.1" + version: 6.1.13 + resolution: "tar@npm:6.1.13" dependencies: chownr: ^2.0.0 fs-minipass: ^2.0.0 - minipass: ^5.0.0 + minipass: ^4.0.0 minizlib: ^2.1.1 mkdirp: ^1.0.3 yallist: ^4.0.0 - checksum: f1322768c9741a25356c11373bce918483f40fa9a25c69c59410c8a1247632487edef5fe76c5f12ac51a6356d2f1829e96d2bc34098668a2fc34d76050ac2b6c + checksum: 8a278bed123aa9f53549b256a36b719e317c8b96fe86a63406f3c62887f78267cea9b22dc6f7007009738509800d4a4dccc444abd71d762287c90f35b002eb1c languageName: node linkType: hard @@ -36260,6 +36373,15 @@ pvutils@latest: languageName: node linkType: hard +"type-fest@npm:5.0.0": + version: 5.0.0 + resolution: "type-fest@npm:5.0.0" + dependencies: + tagged-tag: ^1.0.0 + checksum: 2ae9bc0a5401b2f2061deb8f12c3ce175f8f6ad979c16b9f540fad40b447e035f149ff158e50dbcc0ca7e4d0ac6ba06659f9bf95e366f3dcda0079e76be08af9 + languageName: node + linkType: hard + "type-fest@npm:5.0.1": version: 5.0.1 resolution: "type-fest@npm:5.0.1" @@ -38697,6 +38819,15 @@ pvutils@latest: languageName: node linkType: hard +"yaml@npm:^2.8.0, yaml@npm:^2.8.2": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 5ffd9f23bc7a450129cbd49dcf91418988f154ede10c83fd28ab293661ac2783c05da19a28d76a22cbd77828eae25d4bd7453f9a9fe2d287d085d72db46fd105 + languageName: node + linkType: hard + "yamljs@npm:^0.3.0": version: 0.3.0 resolution: "yamljs@npm:0.3.0" @@ -38869,6 +39000,13 @@ pvutils@latest: languageName: node linkType: hard +"zod@npm:3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 15949ff82118f59c893dacd9d3c766d02b6fa2e71cf474d5aa888570c469dbf5446ac5ad562bb035bf7ac9650da94f290655c194f4a6de3e766f43febd432c5c + languageName: node + linkType: hard + "zod@npm:^3.21.4, zod@npm:^3.23.8": version: 3.25.76 resolution: "zod@npm:3.25.76" @@ -38876,6 +39014,13 @@ pvutils@latest: languageName: node linkType: hard +"zod@npm:^4.1.11": + version: 4.3.5 + resolution: "zod@npm:4.3.5" + checksum: 68691183a91c67c4102db20139f3b5af288c59b4b11eb2239d712aae99dc6c1cecaeebcb0c012b44489771be05fecba21e79f65af4b3163b220239ef0af3ec49 + languageName: node + linkType: hard + "zod@npm:^4.2.1": version: 4.2.1 resolution: "zod@npm:4.2.1" From 5a7deb0afb60e10b67f7a5d0bca932d765840f4e Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:22:47 +1100 Subject: [PATCH 10/46] perf(public-api): remove node_modules from production Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit esbuild bundles all dependencies into server.cjs, so copying the entire node_modules directory to the production image was unnecessary and added significant time to Railway deployments (~2-4 min) and image size (~500MB+). The only externals are fsevents (macOS-only, not used in Linux containers) and @cowprotocol/* (not used by public-api routes). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index d0d009558a8..e716670c861 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -49,10 +49,9 @@ FROM node:22-alpine AS runner WORKDIR /app -# Copy the bundled server and asset data +# Copy only the bundled server and asset data (node_modules not needed - esbuild bundles everything) COPY --from=builder /app/packages/public-api/dist/server.cjs ./server.cjs COPY --from=builder /app/public/generated/generatedAssetData.json ./public/generated/ -COPY --from=builder /app/node_modules ./node_modules # Set environment ENV NODE_ENV=production From c7bd50c8250821173ea13befc4a31c01bd3dcefd Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:32:31 +1100 Subject: [PATCH 11/46] docs(public-api): add integration overview to API documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive description explaining how to integrate the swap API: - Step-by-step integration flow (assets -> rates -> quote -> execute) - Authentication requirements - CAIP-19 asset ID format with examples πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/docs/openapi.ts | 44 ++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index f65c330e7e6..1bd4af746fe 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -228,7 +228,49 @@ export const generateOpenApiDocument = () => { info: { version: '1.0.0', title: 'ShapeShift Public API', - description: 'Public API for ShapeShift swap functionality', + description: `The ShapeShift Public API enables developers to integrate multi-chain swap functionality into their applications. Access rates from multiple DEX aggregators and execute swaps across supported blockchains. + +## Integration Overview + +### 1. Get Supported Assets +First, fetch the list of supported assets to populate your UI: +\`\`\` +GET /v1/assets +\`\`\` + +### 2. Get Swap Rates +When a user wants to swap, fetch rates from all available swappers to find the best deal: +\`\`\` +GET /v1/swap/rates?sellAssetId=eip155:1/slip44:60&buyAssetId=eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&sellAmountCryptoBaseUnit=1000000000000000000 +\`\`\` +This returns rates from THORChain, 0x, CoW Swap, and other supported swappers. + +### 3. Get Executable Quote +Once the user selects a rate, request an executable quote with transaction data: +\`\`\` +POST /v1/swap/quote +{ + "sellAssetId": "eip155:1/slip44:60", + "buyAssetId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "sellAmountCryptoBaseUnit": "1000000000000000000", + "swapperName": "THORChain", + "receiveAddress": "0x...", + "sendAddress": "0x..." +} +\`\`\` + +### 4. Execute the Swap +Use the returned \`transactionData\` to build and sign a transaction with the user's wallet, then broadcast it to the network. + +## Authentication +Include your API key in the \`X-API-Key\` header for all swap endpoints. + +## Asset IDs +Assets use CAIP-19 format: \`{chainId}/{assetNamespace}:{assetReference}\` +- Native ETH: \`eip155:1/slip44:60\` +- USDC on Ethereum: \`eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\` +- Native BTC: \`bip122:000000000019d6689c085ae165831e93/slip44:0\` +`, }, servers: [ { url: 'https://api.shapeshift.com' }, From d402052062a70b5904c87fc2215a260e8a27a6a5 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:34:36 +1100 Subject: [PATCH 12/46] perf(public-api): add BuildKit optimizations to Docker build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Dockerfile.dockerignore to exclude node_modules (4.5GB), .git (1.4GB), and other unnecessary files from build context - Enable BuildKit with syntax directive for advanced features - Add cache mount for yarn berry cache to speed up subsequent builds These optimizations reduce build context transfer time and cache dependencies between builds, further improving Railway deployment speed. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 7 +++-- packages/public-api/Dockerfile.dockerignore | 30 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/public-api/Dockerfile.dockerignore diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index e716670c861..83121b24a7c 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # Build stage FROM node:22-alpine AS builder @@ -21,8 +23,9 @@ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ -# Install dependencies (skip build/postinstall scripts that require git/cypress/etc) -RUN yarn install --immutable --mode skip-build +# Install dependencies with cache mount (skip build/postinstall scripts that require git/cypress/etc) +RUN --mount=type=cache,target=/root/.yarn/berry/cache \ + yarn install --immutable --mode skip-build # Copy source files COPY packages/ ./packages/ diff --git a/packages/public-api/Dockerfile.dockerignore b/packages/public-api/Dockerfile.dockerignore new file mode 100644 index 00000000000..9614c140cf7 --- /dev/null +++ b/packages/public-api/Dockerfile.dockerignore @@ -0,0 +1,30 @@ +# Dependencies (yarn install handles these fresh) +node_modules/ +packages/*/node_modules/ + +# Git +.git/ + +# Build artifacts +build/ +packages/*/dist/ + +# Development files +.env* +!.env.example +*.log +.DS_Store +coverage/ + +# IDE +.idea/ +.vscode/ + +# Test files not needed for build +cypress/ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts + +# Other large/unnecessary directories +.playwright-mcp/ From 152528612dbfba0d3fb5824ca844288844983b5b Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:37:51 +1100 Subject: [PATCH 13/46] fix(public-api): add required id to BuildKit cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Railway requires cache mounts to have an explicit id parameter. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index 83121b24a7c..ee3c8221ad2 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -24,7 +24,7 @@ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ # Install dependencies with cache mount (skip build/postinstall scripts that require git/cypress/etc) -RUN --mount=type=cache,target=/root/.yarn/berry/cache \ +RUN --mount=type=cache,id=yarn-cache,target=/root/.yarn/berry/cache \ yarn install --immutable --mode skip-build # Copy source files From 99e3fe212af826433fb9524da96239c95a59b8a7 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:41:16 +1100 Subject: [PATCH 14/46] fix(public-api): remove BuildKit cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Railway's cache mount requires a specific prefix format that's not documented. Reverting to standard yarn install - the .dockerignore still provides significant build context reduction. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index ee3c8221ad2..e716670c861 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1 - # Build stage FROM node:22-alpine AS builder @@ -23,9 +21,8 @@ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ -# Install dependencies with cache mount (skip build/postinstall scripts that require git/cypress/etc) -RUN --mount=type=cache,id=yarn-cache,target=/root/.yarn/berry/cache \ - yarn install --immutable --mode skip-build +# Install dependencies (skip build/postinstall scripts that require git/cypress/etc) +RUN yarn install --immutable --mode skip-build # Copy source files COPY packages/ ./packages/ From c61df6a29fcbeca4b5ec879976cc5a7ff0514805 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 15:51:49 +1100 Subject: [PATCH 15/46] perf(public-api): add Railway-compatible BuildKit cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Railway's required cache mount ID format: s/- This caches yarn's berry cache between builds to speed up dependency installation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index e716670c861..84e5edbd59d 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # Build stage FROM node:22-alpine AS builder @@ -21,8 +23,9 @@ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ -# Install dependencies (skip build/postinstall scripts that require git/cypress/etc) -RUN yarn install --immutable --mode skip-build +# Install dependencies with Railway cache mount (skip build/postinstall scripts that require git/cypress/etc) +RUN --mount=type=cache,id=s/5fa4b228-de48-4bb3-90d6-807d0c6e7f1c-/root/.yarn/berry/cache,target=/root/.yarn/berry/cache \ + yarn install --immutable --mode skip-build # Copy source files COPY packages/ ./packages/ From 18ad697ab1deeab8257a6d5fb1d986029234efe5 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 16:39:59 +1100 Subject: [PATCH 16/46] chore: add .playwright-mcp to gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8692757aefa..6be6cb15e17 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ yarn-error.log* # idea *.iml .idea/ +.playwright-mcp/ From 56b5c7fdf18e6ecaf32edff4a7ceec265aed2ce1 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 17:13:45 +1100 Subject: [PATCH 17/46] fix(public-api): bundle cowprotocol packages to fix production rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @cowprotocol/* packages were externalized in esbuild which caused them to be missing at runtime in the Docker production image (which only copies the bundled server.cjs, not node_modules). This caused the entire swapper module initialization to fail, making the swappers object undefined and returning empty rates for all requests. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/esbuild.config.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/public-api/esbuild.config.mjs b/packages/public-api/esbuild.config.mjs index 3809a39145b..368664106aa 100644 --- a/packages/public-api/esbuild.config.mjs +++ b/packages/public-api/esbuild.config.mjs @@ -11,8 +11,6 @@ const result = await esbuild.build({ external: [ // Keep native modules external 'fsevents', - // Exclude all cowswap-related packages that have ethers import issues - '@cowprotocol/*', ], alias: { // Alias ethers/lib/utils to ethers which has the utils re-exported From fdd0f0d16e85f9d65ea3f1582dddfd985eb48dc2 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 17:35:12 +1100 Subject: [PATCH 18/46] docs(public-api): use 0x swapper in quote examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THORChain doesn't support native ETH -> ERC20 swaps on the same chain. Update the documentation examples to use 0x which reliably supports ETH -> USDC swaps and returns valid transaction data. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/docs/openapi.ts | 2 +- packages/public-api/src/routes/quote.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index 1bd4af746fe..aea86de9db4 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -253,7 +253,7 @@ POST /v1/swap/quote "sellAssetId": "eip155:1/slip44:60", "buyAssetId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "sellAmountCryptoBaseUnit": "1000000000000000000", - "swapperName": "THORChain", + "swapperName": "0x", "receiveAddress": "0x...", "sendAddress": "0x..." } diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index 3f36f785974..c2f072d544f 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -25,7 +25,7 @@ export const QuoteRequestSchema = z.object({ sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), receiveAddress: z.string().min(1).openapi({ example: '0x0000000000000000000000000000000000000000' }), sendAddress: z.string().optional().openapi({ example: '0x0000000000000000000000000000000000000000' }), - swapperName: z.string().min(1).openapi({ example: 'THORChain' }), + swapperName: z.string().min(1).openapi({ example: '0x' }), slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), accountNumber: z.number().optional().default(0).openapi({ example: 0 }), From b0f1bc62e770cbbf63b53652418cb9ecfce3f1dc Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 17:56:06 +1100 Subject: [PATCH 19/46] fix(public-api): set page title to ShapeShift API Reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/docs/openapi.ts | 4 ++-- packages/public-api/src/routes/docs.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index aea86de9db4..b9acfcc6ec6 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -51,7 +51,7 @@ const QuoteStepSchema = registry.register( buyAmountAfterFeesCryptoBaseUnit: z.string().openapi({ example: '995000000' }), allowanceContract: z.string().openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }), estimatedExecutionTimeMs: z.number().optional().openapi({ example: 60000 }), - source: z.string().openapi({ example: 'Thorchain' }), + source: z.string().openapi({ example: '0x' }), transactionData: z .object({ to: z.string(), @@ -68,7 +68,7 @@ const QuoteResponseSchema = registry.register( 'QuoteResponse', z.object({ quoteId: z.string().uuid(), - swapperName: z.string().openapi({ example: 'Thorchain' }), + swapperName: z.string().openapi({ example: '0x' }), rate: z.string().openapi({ example: '0.995' }), sellAsset: AssetSchema, buyAsset: AssetSchema, diff --git a/packages/public-api/src/routes/docs.ts b/packages/public-api/src/routes/docs.ts index f033a42ba51..024d7cd8e59 100644 --- a/packages/public-api/src/routes/docs.ts +++ b/packages/public-api/src/routes/docs.ts @@ -20,6 +20,7 @@ router.use( spec: { content: openApiDocument, }, + pageTitle: 'ShapeShift API Reference', theme: 'purple', showSidebar: true, hideDownloadButton: true, From fa90b20e737b66e33753c75fe5074d3586bcc4df Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 17:59:08 +1100 Subject: [PATCH 20/46] fix(public-api): use valid example addresses in quote schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null address (0x000...000) is rejected by swapper APIs like 0x. Update the OpenAPI examples to use a valid Ethereum address so the Try It Out feature in the docs actually works. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/routes/quote.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index c2f072d544f..531cabcaea8 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -23,8 +23,8 @@ export const QuoteRequestSchema = z.object({ sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), - receiveAddress: z.string().min(1).openapi({ example: '0x0000000000000000000000000000000000000000' }), - sendAddress: z.string().optional().openapi({ example: '0x0000000000000000000000000000000000000000' }), + receiveAddress: z.string().min(1).openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + sendAddress: z.string().optional().openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), swapperName: z.string().min(1).openapi({ example: '0x' }), slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), From 2c95f0ca0551fe4779985cee04709f1e34c8ecd0 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 18:16:31 +1100 Subject: [PATCH 21/46] fix(public-api): resolve consistent-type-imports lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `typeof import()` type annotations with `Awaited>` pattern to comply with @typescript-eslint/consistent-type-imports rule. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/routes/quote.ts | 50 ++++++++++++++++++------- packages/public-api/src/routes/rates.ts | 25 +++++++++---- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index 531cabcaea8..8f255f59571 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -1,19 +1,25 @@ +import { fromChainId } from '@shapeshiftoss/caip' +import type { + GetTradeQuoteInputWithWallet, + SwapperName, + TradeQuote, + TradeQuoteStep, +} from '@shapeshiftoss/swapper' import type { Request, Response } from 'express' -import { z } from 'zod' import { v4 as uuidv4 } from 'uuid' -import type { TradeQuote, TradeQuoteStep, SwapperName, GetTradeQuoteInputWithWallet } from '@shapeshiftoss/swapper' -import { fromChainId } from '@shapeshiftoss/caip' +import { z } from 'zod' -import { getAssetsById, getAsset } from '../assets' +import { getAsset, getAssetsById } from '../assets' import { DEFAULT_AFFILIATE_BPS } from '../config' import { createServerSwapperDeps } from '../swapperDeps' -import type { QuoteResponse, ApiQuoteStep, ApprovalInfo, ErrorResponse } from '../types' +import type { ApiQuoteStep, ApprovalInfo, ErrorResponse, QuoteResponse } from '../types' // Lazy load swapper to avoid import issues at module load time -let swapperModule: typeof import('@shapeshiftoss/swapper') | null = null +let swapperModule: Awaited> | null = null +const importSwapperModule = () => import('@shapeshiftoss/swapper') const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await import('@shapeshiftoss/swapper') + swapperModule = await importSwapperModule() } return swapperModule } @@ -21,10 +27,19 @@ const getSwapperModule = async () => { // Request validation schema - swapperName is string, validated later export const QuoteRequestSchema = z.object({ sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), - buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), + buyAssetId: z + .string() + .min(1) + .openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), - receiveAddress: z.string().min(1).openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), - sendAddress: z.string().optional().openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + receiveAddress: z + .string() + .min(1) + .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + sendAddress: z + .string() + .optional() + .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), swapperName: z.string().min(1).openapi({ example: '0x' }), slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), @@ -76,7 +91,7 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction } // Helper to build approval info -const buildApprovalInfo = (step: TradeQuoteStep, sellAssetId: string): ApprovalInfo => { +const buildApprovalInfo = (step: TradeQuoteStep, _sellAssetId: string): ApprovalInfo => { const { chainNamespace } = fromChainId(step.sellAsset.chainId) // Only EVM tokens need approval @@ -140,10 +155,13 @@ export const getQuote = async (req: Request, res: Response): Promise => { } = parseResult.data // Lazy load swapper module - const { getTradeQuotes, swappers, SwapperName, getDefaultSlippageDecimalPercentageForSwapper } = await getSwapperModule() + const { getTradeQuotes, swappers, SwapperName, getDefaultSlippageDecimalPercentageForSwapper } = + await getSwapperModule() // Validate swapper name - const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as SwapperName | undefined + const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as + | SwapperName + | undefined if (!validSwapperName) { res.status(400).json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse) return @@ -200,7 +218,11 @@ export const getQuote = async (req: Request, res: Response): Promise => { } // Fetch quote - const result = await getTradeQuotes(quoteInput as GetTradeQuoteInputWithWallet, validSwapperName, deps) + const result = await getTradeQuotes( + quoteInput as GetTradeQuoteInputWithWallet, + validSwapperName, + deps, + ) if (!result) { res.status(404).json({ error: 'No quote available from this swapper' } as ErrorResponse) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 1a81538a7c6..e7eb08eb03c 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -1,16 +1,19 @@ +import type { GetTradeRateInput } from '@shapeshiftoss/swapper' import type { Request, Response } from 'express' import { z } from 'zod' -import { getAssetsById, getAsset } from '../assets' +import { getAsset, getAssetsById } from '../assets' import { DEFAULT_AFFILIATE_BPS } from '../config' import { createServerSwapperDeps } from '../swapperDeps' -import type { ApiRate, RatesResponse, ErrorResponse } from '../types' -import type { GetTradeRateInput } from '@shapeshiftoss/swapper' +import type { ApiRate, ErrorResponse, RatesResponse } from '../types' // Request validation schema export const RatesRequestSchema = z.object({ sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), - buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), + buyAssetId: z + .string() + .min(1) + .openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), @@ -34,10 +37,11 @@ const ENABLED_SWAPPER_NAMES = [ const RATE_TIMEOUT_MS = 10_000 // Lazy load swapper to avoid import issues at module load time -let swapperModule: typeof import('@shapeshiftoss/swapper') | null = null +let swapperModule: Awaited> | null = null +const importSwapperModule = () => import('@shapeshiftoss/swapper') const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await import('@shapeshiftoss/swapper') + swapperModule = await importSwapperModule() } return swapperModule } @@ -101,7 +105,7 @@ export const getRates = async (req: Request, res: Response): Promise => { const enabledSwappers = ENABLED_SWAPPER_NAMES.map(name => { const swapperName = Object.values(SwapperName).find(v => v === name) return swapperName - }).filter((name): name is typeof SwapperName[keyof typeof SwapperName] => name !== undefined) + }).filter((name): name is (typeof SwapperName)[keyof typeof SwapperName] => name !== undefined) // Fetch rates from all enabled swappers in parallel const ratePromises = enabledSwappers.map(async (swapperName): Promise => { @@ -109,7 +113,12 @@ export const getRates = async (req: Request, res: Response): Promise => { const swapper = swappers[swapperName] if (!swapper) return null - const result = await getTradeRates(rateInput as GetTradeRateInput, swapperName, deps, RATE_TIMEOUT_MS) + const result = await getTradeRates( + rateInput as GetTradeRateInput, + swapperName, + deps, + RATE_TIMEOUT_MS, + ) if (!result) return null From bcecc7466415c899891e9ef1b911f3d9c723e552 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 18:18:02 +1100 Subject: [PATCH 22/46] feat(public-api): add API info JSON at root endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns API name, version, description, and available endpoints when visiting the root URL instead of a 404 error. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/index.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts index e3f335d2a7f..efce70190c3 100644 --- a/packages/public-api/src/index.ts +++ b/packages/public-api/src/index.ts @@ -1,14 +1,15 @@ import './setupZod' -import express from 'express' + import cors from 'cors' +import express from 'express' -import { API_PORT, API_HOST } from './config' import { initAssets } from './assets' +import { API_HOST, API_PORT } from './config' import { apiKeyAuth, optionalApiKeyAuth } from './middleware/auth' -import { getRates } from './routes/rates' -import { getQuote } from './routes/quote' -import { getAssets, getAssetById, getAssetCount } from './routes/assets' +import { getAssetById, getAssetCount, getAssets } from './routes/assets' import { docsRouter } from './routes/docs' +import { getQuote } from './routes/quote' +import { getRates } from './routes/rates' const app = express() @@ -16,6 +17,24 @@ const app = express() app.use(cors()) app.use(express.json()) +// Root endpoint - API info +app.get('/', (_req, res) => { + res.json({ + name: 'ShapeShift API', + version: '1.0.0', + description: 'Decentralized swap and asset discovery API', + documentation: '/docs', + endpoints: { + health: 'GET /health', + assets: 'GET /v1/assets', + assetCount: 'GET /v1/assets/count', + assetById: 'GET /v1/assets/:assetId', + swapRates: 'GET /v1/swap/rates', + swapQuote: 'POST /v1/swap/quote', + }, + }) +}) + // Health check (no auth required) app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: Date.now() }) From 3a0ca7f096a102327f0f07942d54bcb177ea6633 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 18:24:55 +1100 Subject: [PATCH 23/46] fix: lint --- packages/public-api/src/assets.ts | 5 ++- packages/public-api/src/config.ts | 42 ++++++++++++------ packages/public-api/src/docs/openapi.ts | 25 +++++------ packages/public-api/src/middleware/auth.ts | 16 ++----- packages/public-api/src/routes/assets.ts | 6 +-- packages/public-api/src/routes/docs.ts | 2 +- packages/public-api/src/server-standalone.ts | 13 ++---- packages/public-api/src/swapperDeps.ts | 45 +++++++++++++------- packages/public-api/src/types.ts | 2 +- 9 files changed, 85 insertions(+), 71 deletions(-) diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts index 5eeb88f0488..c66e1b68ae7 100644 --- a/packages/public-api/src/assets.ts +++ b/packages/public-api/src/assets.ts @@ -9,8 +9,8 @@ let assetIds: AssetId[] = [] let assets: Asset[] = [] let initialized = false -export const initAssets = async (): Promise => { - if (initialized) return +export const initAssets = (): Promise => { + if (initialized) return Promise.resolve() try { // Try to load from the generated asset data file @@ -66,6 +66,7 @@ export const initAssets = async (): Promise => { } initialized = true + return Promise.resolve() } export const getAssetsById = (): AssetsById => assetsById diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index ae154d9935a..44610242c08 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -2,24 +2,38 @@ import type { SwapperConfig } from '@shapeshiftoss/swapper' // Server-side config that mirrors the web app's config but from environment variables export const getServerConfig = (): SwapperConfig => ({ - VITE_UNCHAINED_THORCHAIN_HTTP_URL: process.env.UNCHAINED_THORCHAIN_HTTP_URL || 'https://api.thorchain.shapeshift.com', - VITE_UNCHAINED_MAYACHAIN_HTTP_URL: process.env.UNCHAINED_MAYACHAIN_HTTP_URL || 'https://api.mayachain.shapeshift.com', - VITE_UNCHAINED_COSMOS_HTTP_URL: process.env.UNCHAINED_COSMOS_HTTP_URL || 'https://api.cosmos.shapeshift.com', + VITE_UNCHAINED_THORCHAIN_HTTP_URL: + process.env.UNCHAINED_THORCHAIN_HTTP_URL || 'https://api.thorchain.shapeshift.com', + VITE_UNCHAINED_MAYACHAIN_HTTP_URL: + process.env.UNCHAINED_MAYACHAIN_HTTP_URL || 'https://api.mayachain.shapeshift.com', + VITE_UNCHAINED_COSMOS_HTTP_URL: + process.env.UNCHAINED_COSMOS_HTTP_URL || 'https://api.cosmos.shapeshift.com', VITE_THORCHAIN_NODE_URL: process.env.THORCHAIN_NODE_URL || 'https://thornode.ninerealms.com', VITE_MAYACHAIN_NODE_URL: process.env.MAYACHAIN_NODE_URL || 'https://tendermint.mayachain.info', VITE_TRON_NODE_URL: process.env.TRON_NODE_URL || 'https://api.trongrid.io', VITE_FEATURE_THORCHAINSWAP_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_LONGTAIL === 'true', - VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL === 'true', - VITE_THORCHAIN_MIDGARD_URL: process.env.THORCHAIN_MIDGARD_URL || 'https://midgard.thorchain.info/v2', - VITE_MAYACHAIN_MIDGARD_URL: process.env.MAYACHAIN_MIDGARD_URL || 'https://midgard.mayachain.info/v2', - VITE_UNCHAINED_BITCOIN_HTTP_URL: process.env.UNCHAINED_BITCOIN_HTTP_URL || 'https://api.bitcoin.shapeshift.com', - VITE_UNCHAINED_DOGECOIN_HTTP_URL: process.env.UNCHAINED_DOGECOIN_HTTP_URL || 'https://api.dogecoin.shapeshift.com', - VITE_UNCHAINED_LITECOIN_HTTP_URL: process.env.UNCHAINED_LITECOIN_HTTP_URL || 'https://api.litecoin.shapeshift.com', - VITE_UNCHAINED_BITCOINCASH_HTTP_URL: process.env.UNCHAINED_BITCOINCASH_HTTP_URL || 'https://api.bitcoincash.shapeshift.com', - VITE_UNCHAINED_ETHEREUM_HTTP_URL: process.env.UNCHAINED_ETHEREUM_HTTP_URL || 'https://api.ethereum.shapeshift.com', - VITE_UNCHAINED_AVALANCHE_HTTP_URL: process.env.UNCHAINED_AVALANCHE_HTTP_URL || 'https://api.avalanche.shapeshift.com', - VITE_UNCHAINED_BNBSMARTCHAIN_HTTP_URL: process.env.UNCHAINED_BNBSMARTCHAIN_HTTP_URL || 'https://api.bnbsmartchain.shapeshift.com', - VITE_UNCHAINED_BASE_HTTP_URL: process.env.UNCHAINED_BASE_HTTP_URL || 'https://api.base.shapeshift.com', + VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: + process.env.FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL === 'true', + VITE_THORCHAIN_MIDGARD_URL: + process.env.THORCHAIN_MIDGARD_URL || 'https://midgard.thorchain.info/v2', + VITE_MAYACHAIN_MIDGARD_URL: + process.env.MAYACHAIN_MIDGARD_URL || 'https://midgard.mayachain.info/v2', + VITE_UNCHAINED_BITCOIN_HTTP_URL: + process.env.UNCHAINED_BITCOIN_HTTP_URL || 'https://api.bitcoin.shapeshift.com', + VITE_UNCHAINED_DOGECOIN_HTTP_URL: + process.env.UNCHAINED_DOGECOIN_HTTP_URL || 'https://api.dogecoin.shapeshift.com', + VITE_UNCHAINED_LITECOIN_HTTP_URL: + process.env.UNCHAINED_LITECOIN_HTTP_URL || 'https://api.litecoin.shapeshift.com', + VITE_UNCHAINED_BITCOINCASH_HTTP_URL: + process.env.UNCHAINED_BITCOINCASH_HTTP_URL || 'https://api.bitcoincash.shapeshift.com', + VITE_UNCHAINED_ETHEREUM_HTTP_URL: + process.env.UNCHAINED_ETHEREUM_HTTP_URL || 'https://api.ethereum.shapeshift.com', + VITE_UNCHAINED_AVALANCHE_HTTP_URL: + process.env.UNCHAINED_AVALANCHE_HTTP_URL || 'https://api.avalanche.shapeshift.com', + VITE_UNCHAINED_BNBSMARTCHAIN_HTTP_URL: + process.env.UNCHAINED_BNBSMARTCHAIN_HTTP_URL || 'https://api.bnbsmartchain.shapeshift.com', + VITE_UNCHAINED_BASE_HTTP_URL: + process.env.UNCHAINED_BASE_HTTP_URL || 'https://api.base.shapeshift.com', VITE_COWSWAP_BASE_URL: process.env.COWSWAP_BASE_URL || 'https://api.cow.fi', VITE_PORTALS_BASE_URL: process.env.PORTALS_BASE_URL || 'https://api.portals.fi', VITE_ZRX_BASE_URL: process.env.ZRX_BASE_URL || 'https://api.proxy.shapeshift.com/api/v1/zrx/', diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index b9acfcc6ec6..12f95da6d51 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -1,8 +1,6 @@ import '../setupZod' -import { - OpenAPIRegistry, - OpenApiGeneratorV3, -} from '@asteasolutions/zod-to-openapi' + +import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi' import { z } from 'zod' import { AssetRequestSchema, AssetsListRequestSchema } from '../routes/assets' @@ -38,7 +36,7 @@ const AssetSchema = registry.register( explorer: z.string().openapi({ example: 'https://etherscan.io' }), explorerAddressLink: z.string().openapi({ example: 'https://etherscan.io/address/' }), explorerTxLink: z.string().openapi({ example: 'https://etherscan.io/tx/' }), - }) + }), ) // Quote Response Step @@ -49,7 +47,9 @@ const QuoteStepSchema = registry.register( buyAsset: AssetSchema, sellAmountCryptoBaseUnit: z.string().openapi({ example: '1000000000000000000' }), buyAmountAfterFeesCryptoBaseUnit: z.string().openapi({ example: '995000000' }), - allowanceContract: z.string().openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }), + allowanceContract: z + .string() + .openapi({ example: '0xdef1c0ded9bec7f1a1670819833240f027b25eff' }), estimatedExecutionTimeMs: z.number().optional().openapi({ example: 60000 }), source: z.string().openapi({ example: '0x' }), transactionData: z @@ -60,7 +60,7 @@ const QuoteStepSchema = registry.register( gasLimit: z.string().optional(), }) .optional(), - }) + }), ) // Quote Response @@ -79,7 +79,7 @@ const QuoteResponseSchema = registry.register( slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), steps: z.array(QuoteStepSchema), expiresAt: z.number(), - }) + }), ) // Rate Response @@ -101,11 +101,11 @@ const RateResponseSchema = registry.register( message: z.string(), }) .optional(), - }) + }), ), timestamp: z.number(), expiresAt: z.number(), - }) + }), ) // --- Paths --- @@ -272,9 +272,6 @@ Assets use CAIP-19 format: \`{chainId}/{assetNamespace}:{assetReference}\` - Native BTC: \`bip122:000000000019d6689c085ae165831e93/slip44:0\` `, }, - servers: [ - { url: 'https://api.shapeshift.com' }, - { url: 'http://localhost:3001' }, - ], + servers: [{ url: 'https://api.shapeshift.com' }, { url: 'http://localhost:3001' }], }) } diff --git a/packages/public-api/src/middleware/auth.ts b/packages/public-api/src/middleware/auth.ts index eaa7edff3cd..c859cc5c66e 100644 --- a/packages/public-api/src/middleware/auth.ts +++ b/packages/public-api/src/middleware/auth.ts @@ -1,8 +1,8 @@ -import type { Request, Response, NextFunction } from 'express' import crypto from 'crypto' +import type { NextFunction, Request, Response } from 'express' import { STATIC_API_KEYS } from '../config' -import type { PartnerConfig, ErrorResponse } from '../types' +import type { ErrorResponse, PartnerConfig } from '../types' // Hash an API key for comparison (in production, keys would be stored hashed) const hashApiKey = (key: string): string => { @@ -10,11 +10,7 @@ const hashApiKey = (key: string): string => { } // API key authentication middleware -export const apiKeyAuth = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { +export const apiKeyAuth = (req: Request, res: Response, next: NextFunction): void => { const apiKey = req.header('X-API-Key') if (!apiKey) { @@ -59,11 +55,7 @@ export const apiKeyAuth = async ( } // Optional auth - allows unauthenticated requests but attaches partner info if present -export const optionalApiKeyAuth = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { +export const optionalApiKeyAuth = (req: Request, _res: Response, next: NextFunction): void => { const apiKey = req.header('X-API-Key') if (apiKey) { diff --git a/packages/public-api/src/routes/assets.ts b/packages/public-api/src/routes/assets.ts index a51ab9a3ce6..83aea66732f 100644 --- a/packages/public-api/src/routes/assets.ts +++ b/packages/public-api/src/routes/assets.ts @@ -16,7 +16,7 @@ export const AssetsListRequestSchema = z.object({ offset: z.coerce.number().min(0).optional().default(0).openapi({ example: 0 }), }) -export const getAssets = async (req: Request, res: Response): Promise => { +export const getAssets = (req: Request, res: Response): void => { try { const parseResult = AssetsListRequestSchema.safeParse(req.query) if (!parseResult.success) { @@ -52,7 +52,7 @@ export const getAssets = async (req: Request, res: Response): Promise => { } } -export const getAssetById = async (req: Request, res: Response): Promise => { +export const getAssetById = (req: Request, res: Response): void => { try { const parseResult = AssetRequestSchema.safeParse(req.params) if (!parseResult.success) { @@ -81,7 +81,7 @@ export const getAssetById = async (req: Request, res: Response): Promise = } } -export const getAssetCount = async (_req: Request, res: Response): Promise => { +export const getAssetCount = (_req: Request, res: Response): void => { try { const count = getAssetIds().length res.json({ count, timestamp: Date.now() }) diff --git a/packages/public-api/src/routes/docs.ts b/packages/public-api/src/routes/docs.ts index 024d7cd8e59..0434728335f 100644 --- a/packages/public-api/src/routes/docs.ts +++ b/packages/public-api/src/routes/docs.ts @@ -35,7 +35,7 @@ router.use( customCss: ` .sidebar { --theme-color-1: #383838; } `, - } as any) + } as any), ) export const docsRouter = router diff --git a/packages/public-api/src/server-standalone.ts b/packages/public-api/src/server-standalone.ts index add3cf4a17f..b635106082d 100644 --- a/packages/public-api/src/server-standalone.ts +++ b/packages/public-api/src/server-standalone.ts @@ -6,10 +6,10 @@ * For production, use index.ts which integrates with the real swapper package. */ -import express from 'express' import cors from 'cors' -import { v4 as uuidv4 } from 'uuid' import crypto from 'crypto' +import express from 'express' +import { v4 as uuidv4 } from 'uuid' const API_PORT = parseInt(process.env.PORT || '3001', 10) const API_HOST = process.env.HOST || '0.0.0.0' @@ -109,13 +109,8 @@ app.get('/v1/swap/rates', apiKeyAuth, (req, res) => { // Mock quote endpoint app.post('/v1/swap/quote', apiKeyAuth, (req, res) => { - const { - sellAssetId, - buyAssetId, - sellAmountCryptoBaseUnit, - receiveAddress, - swapperName, - } = req.body + const { sellAssetId, buyAssetId, sellAmountCryptoBaseUnit, receiveAddress, swapperName } = + req.body if (!sellAssetId || !buyAssetId || !sellAmountCryptoBaseUnit || !receiveAddress || !swapperName) { return res.status(400).json({ diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index 8cec37876a9..26e0635cc62 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -3,15 +3,16 @@ import type { ChainAdapter, CosmosSdkChainAdapter, EvmChainAdapter, - UtxoChainAdapter, near, solana, starknet, sui, tron, + UtxoChainAdapter, } from '@shapeshiftoss/chain-adapters' import type { SwapperDeps } from '@shapeshiftoss/swapper' -import { KnownChainIds, type AssetsByIdPartial } from '@shapeshiftoss/types' +import type { AssetsByIdPartial } from '@shapeshiftoss/types' +import { KnownChainIds } from '@shapeshiftoss/types' import { getServerConfig } from './config' @@ -105,7 +106,7 @@ const createStubAdapter = (type: string) => { return () => { throw new Error( `Chain adapter ${type} not implemented in public API. ` + - `This swapper requires chain adapter functionality that is not yet available.` + `This swapper requires chain adapter functionality that is not yet available.`, ) } } @@ -115,25 +116,39 @@ export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperD config: getServerConfig(), mixPanel: undefined, - assertGetChainAdapter: createStubAdapter('generic') as unknown as (chainId: ChainId) => ChainAdapter, + assertGetChainAdapter: createStubAdapter('generic') as unknown as ( + chainId: ChainId, + ) => ChainAdapter, assertGetEvmChainAdapter: ((chainId: ChainId) => { const unchainedUrl = EVM_UNCHAINED_URLS[chainId] if (unchainedUrl) { return createMinimalEvmAdapter(chainId) } - throw new Error( - `Chain adapter EVM for ${chainId} not implemented in public API.` - ) + throw new Error(`Chain adapter EVM for ${chainId} not implemented in public API.`) }) as unknown as (chainId: ChainId) => EvmChainAdapter, - assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as (chainId: ChainId) => UtxoChainAdapter, - assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as (chainId: ChainId) => CosmosSdkChainAdapter, - assertGetSolanaChainAdapter: createStubAdapter('Solana') as unknown as (chainId: ChainId) => solana.ChainAdapter, - assertGetTronChainAdapter: createStubAdapter('Tron') as unknown as (chainId: ChainId) => tron.ChainAdapter, - assertGetSuiChainAdapter: createStubAdapter('Sui') as unknown as (chainId: ChainId) => sui.ChainAdapter, - assertGetNearChainAdapter: createStubAdapter('Near') as unknown as (chainId: ChainId) => near.ChainAdapter, - assertGetStarknetChainAdapter: createStubAdapter('Starknet') as unknown as (chainId: ChainId) => starknet.ChainAdapter, + assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as ( + chainId: ChainId, + ) => UtxoChainAdapter, + assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as ( + chainId: ChainId, + ) => CosmosSdkChainAdapter, + assertGetSolanaChainAdapter: createStubAdapter('Solana') as unknown as ( + chainId: ChainId, + ) => solana.ChainAdapter, + assertGetTronChainAdapter: createStubAdapter('Tron') as unknown as ( + chainId: ChainId, + ) => tron.ChainAdapter, + assertGetSuiChainAdapter: createStubAdapter('Sui') as unknown as ( + chainId: ChainId, + ) => sui.ChainAdapter, + assertGetNearChainAdapter: createStubAdapter('Near') as unknown as ( + chainId: ChainId, + ) => near.ChainAdapter, + assertGetStarknetChainAdapter: createStubAdapter('Starknet') as unknown as ( + chainId: ChainId, + ) => starknet.ChainAdapter, - fetchIsSmartContractAddressQuery: async () => false, + fetchIsSmartContractAddressQuery: () => Promise.resolve(false), }) diff --git a/packages/public-api/src/types.ts b/packages/public-api/src/types.ts index 04b8dea9bf2..edcdbde5a61 100644 --- a/packages/public-api/src/types.ts +++ b/packages/public-api/src/types.ts @@ -1,5 +1,5 @@ import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import type { SwapperName, TradeQuote, TradeRate, TradeQuoteError } from '@shapeshiftoss/swapper' +import type { SwapperName, TradeQuote, TradeQuoteError, TradeRate } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' // Partner configuration From d41406e07adda6c3b2d580ab80ae34cd5ff45f4f Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 18:42:07 +1100 Subject: [PATCH 24/46] fix(public-api): return Promise.resolve() for early return in initAssets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early return when asset data file is not found was returning undefined, which violates the Promise return type. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/assets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts index c66e1b68ae7..3460e982713 100644 --- a/packages/public-api/src/assets.ts +++ b/packages/public-api/src/assets.ts @@ -33,7 +33,7 @@ export const initAssets = (): Promise => { if (!assetDataPath) { console.warn('Asset data file not found in any of the expected locations:', possiblePaths) initialized = true - return + return Promise.resolve() } const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, 'utf8')) From cd4014cc6358c9599af8e27feb7a7a5e9e504ceb Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Wed, 7 Jan 2026 19:14:32 +1100 Subject: [PATCH 25/46] fix(public-api): handle decimal values in buyAmountCryptoBaseUnit sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some swappers return decimal values in buyAmountCryptoBaseUnit which cannot be converted to BigInt. Strip decimal portion and wrap in try-catch to prevent the entire rates endpoint from failing. Also add error handling for swapper module loading failures. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/routes/rates.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index e7eb08eb03c..78c44e961a6 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -81,7 +81,18 @@ export const getRates = async (req: Request, res: Response): Promise => { } // Lazy load swapper module - const { getTradeRates, swappers, SwapperName } = await getSwapperModule() + let swapperModuleResult + try { + swapperModuleResult = await getSwapperModule() + } catch (error) { + console.error('Failed to load swapper module:', error) + res.status(503).json({ + error: 'Swap service temporarily unavailable', + details: 'Failed to initialize swapper module', + } as ErrorResponse) + return + } + const { getTradeRates, swappers, SwapperName } = swapperModuleResult // Create swapper dependencies const deps = createServerSwapperDeps(getAssetsById()) @@ -175,7 +186,13 @@ export const getRates = async (req: Request, res: Response): Promise => { rates.sort((a, b) => { if (a.error && !b.error) return 1 if (!a.error && b.error) return -1 - return BigInt(b.buyAmountCryptoBaseUnit) > BigInt(a.buyAmountCryptoBaseUnit) ? 1 : -1 + try { + const aBuyAmount = BigInt(a.buyAmountCryptoBaseUnit.split('.')[0]) + const bBuyAmount = BigInt(b.buyAmountCryptoBaseUnit.split('.')[0]) + return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0 + } catch { + return 0 + } }) const now = Date.now() From 83778ce9ffb067425eaa540689faaa5a940544be Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Thu, 8 Jan 2026 10:34:22 +1100 Subject: [PATCH 26/46] fix(utils): use lodash-es for ESM compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The utils package has "type": "module" but was importing from lodash which doesn't support ESM named exports. This caused runtime errors when running directly with Node.js ESM (e.g., public-api dev server). Changed lodash to lodash-es which is the official ESM-compatible version. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/utils/package.json | 4 ++-- .../utils/src/assetData/decodeAssetData.ts | 2 +- packages/utils/src/index.ts | 2 +- yarn.lock | 23 ++++++++++++++++--- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index c36fc0d6928..b3a4a40c15a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -35,9 +35,9 @@ "@sniptt/monads": "^0.5.10", "bignumber.js": "^9.3.1", "dayjs": "^1.11.3", - "lodash": "^4.17.21" + "lodash-es": "^4.17.21" }, "devDependencies": { - "@types/lodash": "^4.14.178" + "@types/lodash-es": "^4.17.12" } } diff --git a/packages/utils/src/assetData/decodeAssetData.ts b/packages/utils/src/assetData/decodeAssetData.ts index 03eaf121236..ec102f33d16 100644 --- a/packages/utils/src/assetData/decodeAssetData.ts +++ b/packages/utils/src/assetData/decodeAssetData.ts @@ -1,7 +1,7 @@ import type { AssetId } from '@shapeshiftoss/caip' import { fromAssetId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' -import { pick } from 'lodash' +import { pick } from 'lodash-es' import { assertUnreachable } from '../assertUnreachable' import { FIELDS } from './constants' diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 96b5efd1212..909d563dc58 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,6 @@ import type { AssetId } from '@shapeshiftoss/caip' import { ASSET_NAMESPACE, fromAssetId } from '@shapeshiftoss/caip' -import { isNull, isUndefined } from 'lodash' +import { isNull, isUndefined } from 'lodash-es' export * from './assertUnreachable' export * from './assetData' diff --git a/yarn.lock b/yarn.lock index d5ed4ddb0b9..51861f05853 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9430,7 +9430,7 @@ __metadata: languageName: node linkType: hard -"@mysten/sui@npm:^1.0.5, @mysten/sui@npm:^1.3.0": +"@mysten/sui@npm:^1.0.5, @mysten/sui@npm:^1.3.0, @mysten/sui@npm:^1.45.2": version: 1.45.2 resolution: "@mysten/sui@npm:1.45.2" dependencies: @@ -12788,6 +12788,7 @@ __metadata: "@cowprotocol/app-data": ^2.3.0 "@defuse-protocol/one-click-sdk-typescript": ^0.1.1-0.2 "@jup-ag/api": ^6.0.30 + "@mysten/sui": ^1.45.2 "@shapeshiftoss/bitcoinjs-lib": 7.0.0-shapeshift.0 "@shapeshiftoss/caip": "workspace:^" "@shapeshiftoss/chain-adapters": "workspace:^" @@ -12901,10 +12902,10 @@ __metadata: "@shapeshiftoss/caip": "workspace:^" "@shapeshiftoss/types": "workspace:^" "@sniptt/monads": ^0.5.10 - "@types/lodash": ^4.14.178 + "@types/lodash-es": ^4.17.12 bignumber.js: ^9.3.1 dayjs: ^1.11.3 - lodash: ^4.17.21 + lodash-es: ^4.17.21 languageName: unknown linkType: soft @@ -15402,6 +15403,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash-es@npm:^4.17.12": + version: 4.17.12 + resolution: "@types/lodash-es@npm:4.17.12" + dependencies: + "@types/lodash": "*" + checksum: 990a99e2243bebe9505cb5ad19fbc172beb4a8e00f9075c99fc06c46c2801ffdb40bc2867271cf580d5f48994fc9fb076ec92cd60a20e621603bf22114e5b077 + languageName: node + linkType: hard + "@types/lodash.clonedeep@npm:^4.5.7": version: 4.5.7 resolution: "@types/lodash.clonedeep@npm:4.5.7" @@ -28971,6 +28981,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:^4.17.21": + version: 4.17.22 + resolution: "lodash-es@npm:4.17.22" + checksum: 1da119f40e54822fb5d69eeb9d2c7ec46ac541cc73e6250748838abd7dd51ecf613592e07a5476f56bcbbeb27838697a4347d68cd98f131cad8c4665f9bb2a16 + languageName: node + linkType: hard + "lodash.clonedeep@npm:^4.5.0": version: 4.5.0 resolution: "lodash.clonedeep@npm:4.5.0" From 88047fa7d3a08aa6dae2d66769528224e84a50ff Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Thu, 8 Jan 2026 10:39:30 +1100 Subject: [PATCH 27/46] fix(public-api): handle undefined array access in buyAmountCryptoBaseUnit sort Co-Authored-By: Claude Opus 4.5 --- packages/public-api/src/routes/rates.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 78c44e961a6..03b227f74f1 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -187,8 +187,8 @@ export const getRates = async (req: Request, res: Response): Promise => { if (a.error && !b.error) return 1 if (!a.error && b.error) return -1 try { - const aBuyAmount = BigInt(a.buyAmountCryptoBaseUnit.split('.')[0]) - const bBuyAmount = BigInt(b.buyAmountCryptoBaseUnit.split('.')[0]) + const aBuyAmount = BigInt(a.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') + const bBuyAmount = BigInt(b.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0 } catch { return 0 From 611026f03de45dd9b7aab061a2b01dbd671a2906 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Thu, 8 Jan 2026 10:42:24 +1100 Subject: [PATCH 28/46] fix(swapper): use direct @mysten/sui import in CetusSwapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Transaction from @mysten/sui/transactions directly instead of reaching into @cetusprotocol/aggregator-sdk's node_modules. Added @mysten/sui as a direct dependency. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/swapper/package.json | 1 + packages/swapper/src/swappers/CetusSwapper/endpoints.ts | 2 +- .../src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts | 2 +- .../src/swappers/CetusSwapper/swapperApi/getTradeRate.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/swapper/package.json b/packages/swapper/package.json index 8a3c8ca608b..244386731ea 100644 --- a/packages/swapper/package.json +++ b/packages/swapper/package.json @@ -36,6 +36,7 @@ "@cowprotocol/app-data": "^2.3.0", "@defuse-protocol/one-click-sdk-typescript": "^0.1.1-0.2", "@jup-ag/api": "^6.0.30", + "@mysten/sui": "^1.45.2", "@shapeshiftoss/bitcoinjs-lib": "7.0.0-shapeshift.0", "@shapeshiftoss/caip": "workspace:^", "@shapeshiftoss/chain-adapters": "workspace:^", diff --git a/packages/swapper/src/swappers/CetusSwapper/endpoints.ts b/packages/swapper/src/swappers/CetusSwapper/endpoints.ts index 74abaa62c16..3a5c2875d0d 100644 --- a/packages/swapper/src/swappers/CetusSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/CetusSwapper/endpoints.ts @@ -1,5 +1,5 @@ import { getProvidersExcluding } from '@cetusprotocol/aggregator-sdk' -import { Transaction } from '@cetusprotocol/aggregator-sdk/node_modules/@mysten/sui/transactions' +import { Transaction } from '@mysten/sui/transactions' import { TxStatus } from '@shapeshiftoss/unchained-client' import { bnOrZero } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' diff --git a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts index 6841ae00fc5..44957f76228 100644 --- a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts @@ -1,4 +1,4 @@ -import { Transaction } from '@cetusprotocol/aggregator-sdk/node_modules/@mysten/sui/transactions' +import { Transaction } from '@mysten/sui/transactions' import { bnOrZero } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err } from '@sniptt/monads' diff --git a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeRate.ts b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeRate.ts index 82aa09f4978..7cc7e0025fa 100644 --- a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeRate.ts +++ b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeRate.ts @@ -1,4 +1,4 @@ -import { Transaction } from '@cetusprotocol/aggregator-sdk/node_modules/@mysten/sui/transactions' +import { Transaction } from '@mysten/sui/transactions' import { bnOrZero } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err } from '@sniptt/monads' From 87bef29e4c0244127960b206b07fb403a09eca81 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Thu, 8 Jan 2026 11:15:49 +1100 Subject: [PATCH 29/46] feat(public-api): add smoke test suite for Railway deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a non-blocking smoke test suite that can run against the deployed API: - 6 critical tests: health check, asset endpoints, auth validation - 2 informative tests: EVM same-chain rates (ETHβ†’USDC), cross-chain rates (ETHβ†’BTC) Tests are bundled with esbuild and included in the Docker image. They always exit 0 to avoid blocking deployment - failures are logged but don't prevent the service from starting. Run locally: `yarn test:smoke` Run against prod: `API_URL=https://api.example.com yarn test:smoke` πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/public-api/Dockerfile | 30 +-- packages/public-api/esbuild.smoke-tests.mjs | 15 ++ packages/public-api/package.json | 2 + packages/public-api/railway.toml | 4 + packages/public-api/tests/run-smoke-tests.ts | 76 +++++++ packages/public-api/tests/smoke-tests.ts | 203 +++++++++++++++++++ packages/public-api/tests/test-config.ts | 62 ++++++ packages/public-api/tests/test-utils.ts | 55 +++++ 8 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 packages/public-api/esbuild.smoke-tests.mjs create mode 100644 packages/public-api/tests/run-smoke-tests.ts create mode 100644 packages/public-api/tests/smoke-tests.ts create mode 100644 packages/public-api/tests/test-config.ts create mode 100644 packages/public-api/tests/test-utils.ts diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index 84e5edbd59d..fab29091929 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -27,33 +27,39 @@ COPY packages/errors/package.json ./packages/errors/ RUN --mount=type=cache,id=s/5fa4b228-de48-4bb3-90d6-807d0c6e7f1c-/root/.yarn/berry/cache,target=/root/.yarn/berry/cache \ yarn install --immutable --mode skip-build -# Copy source files +# Copy unchained-client config and generator files FIRST (rarely changes, enables layer caching) +COPY packages/unchained-client/openapitools.json ./packages/unchained-client/ +COPY packages/unchained-client/generator/ ./packages/unchained-client/generator/ + +# Generate unchained-client code (cached when openapitools.json unchanged) +# Cache mount for OpenAPI Generator JAR to avoid re-downloading on each build +RUN --mount=type=cache,id=s/5fa4b228-de48-4bb3-90d6-807d0c6e7f1c-/root/.openapitools,target=/root/.openapitools \ + yarn workspace @shapeshiftoss/unchained-client generate + +# Copy remaining source files COPY packages/ ./packages/ COPY public/generated/generatedAssetData.json ./public/generated/ COPY tsconfig.json tsconfig.packages.json ./ -# Build packages that public-api depends on -RUN yarn workspace @shapeshiftoss/errors build && \ - yarn workspace @shapeshiftoss/types build && \ - yarn workspace @shapeshiftoss/contracts build && \ - yarn workspace @shapeshiftoss/caip build && \ - yarn workspace @shapeshiftoss/utils build && \ - yarn workspace @shapeshiftoss/unchained-client build && \ - yarn workspace @shapeshiftoss/chain-adapters build && \ - yarn workspace @shapeshiftoss/swapper build && \ - yarn workspace @shapeshiftoss/public-api build +# Build all packages using parallel tsc with project references +RUN yarn run -T tsc --build tsconfig.packages.json && \ + yarn workspaces foreach -ptiv --include '@shapeshiftoss/{errors,types,contracts,caip,utils,unchained-client,chain-adapters,swapper,public-api}' run postbuild # Build the public-api bundle WORKDIR /app/packages/public-api RUN yarn node esbuild.config.mjs +# Build smoke tests bundle +RUN yarn node esbuild.smoke-tests.mjs + # Production stage FROM node:22-alpine AS runner WORKDIR /app -# Copy only the bundled server and asset data (node_modules not needed - esbuild bundles everything) +# Copy only the bundled server, smoke tests, and asset data (node_modules not needed - esbuild bundles everything) COPY --from=builder /app/packages/public-api/dist/server.cjs ./server.cjs +COPY --from=builder /app/packages/public-api/dist/smoke-tests.cjs ./smoke-tests.cjs COPY --from=builder /app/public/generated/generatedAssetData.json ./public/generated/ # Set environment diff --git a/packages/public-api/esbuild.smoke-tests.mjs b/packages/public-api/esbuild.smoke-tests.mjs new file mode 100644 index 00000000000..5426e7227c1 --- /dev/null +++ b/packages/public-api/esbuild.smoke-tests.mjs @@ -0,0 +1,15 @@ +import * as esbuild from 'esbuild' + +const result = await esbuild.build({ + entryPoints: ['tests/run-smoke-tests.ts'], + bundle: true, + outfile: 'dist/smoke-tests.cjs', + platform: 'node', + target: 'node20', + format: 'cjs', + sourcemap: true, + external: ['fsevents'], + logLevel: 'info', +}) + +console.log('Smoke tests build complete:', result) diff --git a/packages/public-api/package.json b/packages/public-api/package.json index 8afd504ef0d..45fc380ea63 100644 --- a/packages/public-api/package.json +++ b/packages/public-api/package.json @@ -10,10 +10,12 @@ "scripts": { "build": "yarn clean && yarn run -T tsc --build", "build:bundle": "node esbuild.config.mjs", + "build:smoke-tests": "node esbuild.smoke-tests.mjs", "clean": "rm -rf dist", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "start:prod": "node dist/server.cjs", + "test:smoke": "tsx tests/run-smoke-tests.ts", "docker:build": "docker build -t shapeshift-public-api -f Dockerfile ../../", "docker:run": "docker run -p 3001:3001 --env-file .env shapeshift-public-api" }, diff --git a/packages/public-api/railway.toml b/packages/public-api/railway.toml index ffb151bae00..4badc5a3e70 100644 --- a/packages/public-api/railway.toml +++ b/packages/public-api/railway.toml @@ -7,6 +7,10 @@ watchPatterns = [ "packages/caip/**", "packages/types/**", "packages/utils/**", + "packages/unchained-client/**", + "packages/chain-adapters/**", + "packages/contracts/**", + "packages/errors/**", "public/generated/generatedAssetData.json" ] diff --git a/packages/public-api/tests/run-smoke-tests.ts b/packages/public-api/tests/run-smoke-tests.ts new file mode 100644 index 00000000000..7510bad22ad --- /dev/null +++ b/packages/public-api/tests/run-smoke-tests.ts @@ -0,0 +1,76 @@ +import { runSmokeTests } from './smoke-tests' +import { sleep } from './test-utils' + +const main = async (): Promise => { + console.log('='.repeat(60)) + console.log('ShapeShift Public API - Smoke Tests') + console.log('='.repeat(60)) + + const apiUrl = process.env.API_URL || 'http://localhost:3001' + console.log(`Testing API at: ${apiUrl}`) + console.log('') + + // Wait for API to be ready (Railway may still be spinning up) + const maxRetries = 10 + const retryDelay = 3000 + + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(`${apiUrl}/health`) + if (res.ok) { + console.log('API is ready!') + break + } + } catch { + // Ignore - API not ready yet + } + + if (i < maxRetries - 1) { + console.log(`Waiting for API to be ready... (${i + 1}/${maxRetries})`) + await sleep(retryDelay) + } else { + console.error('API did not become ready in time') + // Still exit 0 to not block deployment + process.exit(0) + } + } + + console.log('') + const results = await runSmokeTests() + + // Print results + console.log('\nTest Results:') + console.log('-'.repeat(60)) + + for (const result of results.results) { + const status = result.passed ? 'PASS' : result.critical ? 'FAIL (CRITICAL)' : 'FAIL (warning)' + const icon = result.passed ? '[OK]' : '[!!]' + console.log(`${icon} ${result.name}: ${status} (${result.duration}ms)`) + if (result.error) { + console.log(` Error: ${result.error}`) + } + } + + console.log('-'.repeat(60)) + console.log( + `Total: ${results.totalTests} | Passed: ${results.passed} | Failed: ${results.failed}`, + ) + + if (results.criticalFailures > 0) { + console.log(`\n[WARNING] ${results.criticalFailures} CRITICAL test(s) failed!`) + console.log('The API may not be functioning correctly.') + } + + // Output JSON for programmatic consumption + console.log('\n--- JSON Results ---') + console.log(JSON.stringify(results, null, 2)) + + // Always exit 0 to not block deployment + // Critical failures are logged but don't fail the deploy + process.exit(0) +} + +main().catch(err => { + console.error('Smoke test runner error:', err) + process.exit(0) // Still exit 0 to not block +}) diff --git a/packages/public-api/tests/smoke-tests.ts b/packages/public-api/tests/smoke-tests.ts new file mode 100644 index 00000000000..caa752fd85b --- /dev/null +++ b/packages/public-api/tests/smoke-tests.ts @@ -0,0 +1,203 @@ +import { ASSET_IDS, TEST_API_KEY, TEST_PAIRS } from './test-config' +import type { TestResult, TestSuiteResult } from './test-utils' +import { fetchWithTimeout, runTest } from './test-utils' + +const getApiUrl = (): string => process.env.API_URL || 'http://localhost:3001' + +export const runSmokeTests = async (): Promise => { + const API_URL = getApiUrl() + const results: TestResult[] = [] + + // 1. Health Check (Critical) + results.push( + await runTest('Health check', true, async () => { + const res = await fetchWithTimeout(`${API_URL}/health`, {}) + if (!res.ok) throw new Error(`Health check failed: ${res.status}`) + const data = (await res.json()) as { status?: string } + if (data.status !== 'ok') throw new Error('Health status not ok') + }), + ) + + // 2. Asset Count (Critical) + results.push( + await runTest('Asset count', true, async () => { + const res = await fetchWithTimeout(`${API_URL}/v1/assets/count`, {}) + if (!res.ok) throw new Error(`Asset count failed: ${res.status}`) + const data = (await res.json()) as { count?: number } + if (typeof data.count !== 'number' || data.count === 0) { + throw new Error(`Invalid asset count: ${data.count}`) + } + }), + ) + + // 3. Asset List (Critical) + results.push( + await runTest('Asset list', true, async () => { + const res = await fetchWithTimeout(`${API_URL}/v1/assets?limit=10`, {}) + if (!res.ok) throw new Error(`Asset list failed: ${res.status}`) + const data = (await res.json()) as { assets?: unknown[] } + if (!Array.isArray(data.assets) || data.assets.length === 0) { + throw new Error('No assets returned') + } + }), + ) + + // 4. Single Asset Lookup (Critical) + results.push( + await runTest('Single asset lookup (ETH)', true, async () => { + const assetId = encodeURIComponent(ASSET_IDS.ETH) + const res = await fetchWithTimeout(`${API_URL}/v1/assets/${assetId}`, {}) + if (!res.ok) throw new Error(`Asset lookup failed: ${res.status}`) + const asset = (await res.json()) as { chainId?: string; symbol?: string } + if (!asset.chainId || !asset.symbol) { + throw new Error('Invalid asset structure') + } + }), + ) + + // 5. Rates Auth Check (Critical) + results.push( + await runTest('Rates requires auth', true, async () => { + const params = new URLSearchParams({ + sellAssetId: ASSET_IDS.ETH, + buyAssetId: ASSET_IDS.USDC_ETH, + sellAmountCryptoBaseUnit: '100000000000000000', + }) + const res = await fetchWithTimeout(`${API_URL}/v1/swap/rates?${params}`, {}) + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`) + }), + ) + + // 6. Quote Auth Check (Critical) + results.push( + await runTest('Quote requires auth', true, async () => { + const res = await fetchWithTimeout(`${API_URL}/v1/swap/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sellAssetId: ASSET_IDS.ETH, + buyAssetId: ASSET_IDS.USDC_ETH, + sellAmountCryptoBaseUnit: '100000000000000000', + receiveAddress: '0x0000000000000000000000000000000000000000', + swapperName: '0x', + }), + }) + if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`) + }), + ) + + // 7. EVM Same-Chain Rates (Informative) + const evmPair = TEST_PAIRS.evmSameChain[0] + results.push( + await runTest(`Rates: ${evmPair.name}`, false, async () => { + const params = new URLSearchParams({ + sellAssetId: evmPair.sellAssetId, + buyAssetId: evmPair.buyAssetId, + sellAmountCryptoBaseUnit: evmPair.sellAmountCryptoBaseUnit, + }) + const res = await fetchWithTimeout( + `${API_URL}/v1/swap/rates?${params}`, + { + headers: { 'X-API-Key': TEST_API_KEY }, + }, + 30000, + ) // 30s timeout for rates + + if (!res.ok) throw new Error(`Rates request failed: ${res.status}`) + const data = (await res.json()) as { + rates?: { + swapperName: string + error?: unknown + buyAmountCryptoBaseUnit?: string + }[] + } + if (!Array.isArray(data.rates)) throw new Error('Invalid rates response structure') + + const validRates = data.rates.filter( + r => !r.error && r.buyAmountCryptoBaseUnit && r.buyAmountCryptoBaseUnit !== '0', + ) + if (validRates.length === 0) { + const swappersWithErrors = data.rates + .filter(r => r.error) + .map(r => r.swapperName) + .join(', ') + console.warn( + `Warning: No valid rates returned for ${evmPair.name}. Swappers with errors: ${ + swappersWithErrors || 'none' + }`, + ) + } else { + console.log( + ` Found ${validRates.length} valid rate(s) from: ${validRates + .map(r => r.swapperName) + .join(', ')}`, + ) + } + }), + ) + + // 8. Cross-Chain Rates (Informative) + const crossChainPair = TEST_PAIRS.crossChain[0] + results.push( + await runTest(`Rates: ${crossChainPair.name}`, false, async () => { + const params = new URLSearchParams({ + sellAssetId: crossChainPair.sellAssetId, + buyAssetId: crossChainPair.buyAssetId, + sellAmountCryptoBaseUnit: crossChainPair.sellAmountCryptoBaseUnit, + }) + const res = await fetchWithTimeout( + `${API_URL}/v1/swap/rates?${params}`, + { + headers: { 'X-API-Key': TEST_API_KEY }, + }, + 30000, + ) + + if (!res.ok) throw new Error(`Rates request failed: ${res.status}`) + const data = (await res.json()) as { + rates?: { + swapperName: string + error?: unknown + buyAmountCryptoBaseUnit?: string + }[] + } + if (!Array.isArray(data.rates)) throw new Error('Invalid rates response structure') + + const validRates = data.rates.filter( + r => !r.error && r.buyAmountCryptoBaseUnit && r.buyAmountCryptoBaseUnit !== '0', + ) + if (validRates.length === 0) { + const swappersWithErrors = data.rates + .filter(r => r.error) + .map(r => r.swapperName) + .join(', ') + console.warn( + `Warning: No valid rates returned for ${crossChainPair.name}. Swappers with errors: ${ + swappersWithErrors || 'none' + }`, + ) + } else { + console.log( + ` Found ${validRates.length} valid rate(s) from: ${validRates + .map(r => r.swapperName) + .join(', ')}`, + ) + } + }), + ) + + // Calculate summary + const passed = results.filter(r => r.passed).length + const failed = results.filter(r => !r.passed).length + const criticalFailures = results.filter(r => !r.passed && r.critical).length + + return { + timestamp: Date.now(), + apiUrl: API_URL, + totalTests: results.length, + passed, + failed, + criticalFailures, + results, + } +} diff --git a/packages/public-api/tests/test-config.ts b/packages/public-api/tests/test-config.ts new file mode 100644 index 00000000000..7ce270ba947 --- /dev/null +++ b/packages/public-api/tests/test-config.ts @@ -0,0 +1,62 @@ +export const TEST_API_KEY = 'test-api-key-123' + +export const ASSET_IDS = { + // EVM Native Assets + ETH: 'eip155:1/slip44:60', + ARB_ETH: 'eip155:42161/slip44:60', + BASE_ETH: 'eip155:8453/slip44:60', + + // EVM ERC20 Tokens + USDC_ETH: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + USDC_ARB: 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', + FOX_ETH: 'eip155:1/erc20:0xc770eefad204b5180df6a14ee197d99d808ee52d', + + // UTXO + BTC: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + DOGE: 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3', + + // Cosmos + ATOM: 'cosmos:cosmoshub-4/slip44:118', + RUNE: 'cosmos:thorchain-1/slip44:931', +} as const + +export type TestPair = { + name: string + sellAssetId: string + buyAssetId: string + sellAmountCryptoBaseUnit: string + expectedSwappers: string[] +} + +export const TEST_PAIRS: Record = { + evmSameChain: [ + { + name: 'ETH to USDC (Ethereum)', + sellAssetId: ASSET_IDS.ETH, + buyAssetId: ASSET_IDS.USDC_ETH, + sellAmountCryptoBaseUnit: '100000000000000000', // 0.1 ETH + expectedSwappers: ['0x', 'CoW Swap', 'Portals', 'Bebop', 'ButterSwap'], + }, + ], + crossChain: [ + { + name: 'ETH to BTC (THORChain/Chainflip)', + sellAssetId: ASSET_IDS.ETH, + buyAssetId: ASSET_IDS.BTC, + sellAmountCryptoBaseUnit: '100000000000000000', // 0.1 ETH + expectedSwappers: ['THORChain', 'Chainflip', 'Relay'], + }, + ], +} + +export const CRITICAL_SWAPPERS = ['THORChain', '0x'] +export const NON_CRITICAL_SWAPPERS = [ + 'CoW Swap', + 'Portals', + 'Bebop', + 'ButterSwap', + 'Jupiter', + 'Chainflip', + 'MAYAChain', + 'Relay', +] diff --git a/packages/public-api/tests/test-utils.ts b/packages/public-api/tests/test-utils.ts new file mode 100644 index 00000000000..cb207104c85 --- /dev/null +++ b/packages/public-api/tests/test-utils.ts @@ -0,0 +1,55 @@ +export type TestResult = { + name: string + passed: boolean + critical: boolean + duration: number + error?: string + details?: unknown +} + +export type TestSuiteResult = { + timestamp: number + apiUrl: string + totalTests: number + passed: number + failed: number + criticalFailures: number + results: TestResult[] +} + +export const fetchWithTimeout = async ( + url: string, + options: RequestInit = {}, + timeoutMs = 15000, +): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await fetch(url, { ...options, signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + +export const runTest = async ( + name: string, + critical: boolean, + testFn: () => Promise, +): Promise => { + const start = Date.now() + try { + await testFn() + return { name, passed: true, critical, duration: Date.now() - start } + } catch (error) { + return { + name, + passed: false, + critical, + duration: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + } + } +} + +export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) From 522202f7d822a824ed7a735bb74862e002dc6598 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 16:58:31 +1100 Subject: [PATCH 30/46] fix(railway): move swap-widget config to package level Remove root-level railway.json which was overriding public-api config. Each service now has its own railway.toml in its package directory. --- packages/swap-widget/railway.toml | 10 ++++++++++ railway.json | 11 ----------- railway.public-api.toml | 22 ---------------------- 3 files changed, 10 insertions(+), 33 deletions(-) create mode 100644 packages/swap-widget/railway.toml delete mode 100644 railway.json delete mode 100644 railway.public-api.toml diff --git a/packages/swap-widget/railway.toml b/packages/swap-widget/railway.toml new file mode 100644 index 00000000000..a2c100bbd71 --- /dev/null +++ b/packages/swap-widget/railway.toml @@ -0,0 +1,10 @@ +[build] +builder = "dockerfile" +dockerfilePath = "packages/swap-widget/Dockerfile" +watchPatterns = [ + "packages/swap-widget/**" +] + +[deploy] +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 10 diff --git a/railway.json b/railway.json deleted file mode 100644 index 46cd2ac60bd..00000000000 --- a/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.app/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "packages/swap-widget/Dockerfile" - }, - "deploy": { - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 10 - } -} diff --git a/railway.public-api.toml b/railway.public-api.toml deleted file mode 100644 index 89c10dea327..00000000000 --- a/railway.public-api.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build] -builder = "dockerfile" -dockerfilePath = "packages/public-api/Dockerfile" -watchPatterns = [ - "packages/public-api/**", - "packages/swapper/**", - "packages/caip/**", - "packages/types/**", - "packages/utils/**", - "packages/chain-adapters/**", - "packages/unchained-client/**", - "packages/contracts/**", - "packages/errors/**", - "public/generated/generatedAssetData.json" -] - -[deploy] -healthcheckPath = "/health" -healthcheckTimeout = 60 -startCommand = "node server.cjs" -restartPolicyType = "on_failure" -restartPolicyMaxRetries = 5 From 8c688477f597b8e6b9104dd0d25a8b44dfce2101 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 17:24:16 +1100 Subject: [PATCH 31/46] fix(public-api): include all workspace packages in Dockerfile The yarn.lock is generated for the entire monorepo workspace. When only a subset of package.json files are copied, yarn install --immutable fails because the workspace definition (packages/*) doesn't match what's actually present. Added swap-widget/package.json to ensure all workspaces are present. --- packages/public-api/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/public-api/Dockerfile b/packages/public-api/Dockerfile index fab29091929..2814f6d748d 100644 --- a/packages/public-api/Dockerfile +++ b/packages/public-api/Dockerfile @@ -12,7 +12,7 @@ RUN apk add --no-cache python3 make g++ openjdk17-jre COPY package.json yarn.lock .yarnrc.yml ./ COPY .yarn ./.yarn -# Copy all package.json files for workspace resolution +# Copy ALL package.json files for workspace resolution (yarn requires all workspaces to be present) COPY packages/public-api/package.json ./packages/public-api/ COPY packages/caip/package.json ./packages/caip/ COPY packages/types/package.json ./packages/types/ @@ -22,6 +22,7 @@ COPY packages/chain-adapters/package.json ./packages/chain-adapters/ COPY packages/unchained-client/package.json ./packages/unchained-client/ COPY packages/contracts/package.json ./packages/contracts/ COPY packages/errors/package.json ./packages/errors/ +COPY packages/swap-widget/package.json ./packages/swap-widget/ # Install dependencies with Railway cache mount (skip build/postinstall scripts that require git/cypress/etc) RUN --mount=type=cache,id=s/5fa4b228-de48-4bb3-90d6-807d0c6e7f1c-/root/.yarn/berry/cache,target=/root/.yarn/berry/cache \ From 63a8c98341fd45dbe8e7d7dea1bbeba633a89da4 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 17:36:20 +1100 Subject: [PATCH 32/46] fix(public-api): add missing assertGetTonChainAdapter to SwapperDeps --- packages/public-api/src/swapperDeps.ts | 132 ++++++++++++++----------- 1 file changed, 72 insertions(+), 60 deletions(-) diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index 26e0635cc62..e61e9ed86b6 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -1,4 +1,4 @@ -import type { ChainId } from '@shapeshiftoss/caip' +import type { ChainId } from "@shapeshiftoss/caip"; import type { ChainAdapter, CosmosSdkChainAdapter, @@ -7,45 +7,50 @@ import type { solana, starknet, sui, + ton, tron, UtxoChainAdapter, -} from '@shapeshiftoss/chain-adapters' -import type { SwapperDeps } from '@shapeshiftoss/swapper' -import type { AssetsByIdPartial } from '@shapeshiftoss/types' -import { KnownChainIds } from '@shapeshiftoss/types' +} from "@shapeshiftoss/chain-adapters"; +import type { SwapperDeps } from "@shapeshiftoss/swapper"; +import type { AssetsByIdPartial } from "@shapeshiftoss/types"; +import { KnownChainIds } from "@shapeshiftoss/types"; -import { getServerConfig } from './config' +import { getServerConfig } from "./config"; type GasFeeData = { - gasPrice: string - maxFeePerGas?: string - maxPriorityFeePerGas?: string -} + gasPrice: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; type GasFeeDataEstimate = { - fast: GasFeeData - average: GasFeeData - slow: GasFeeData -} + fast: GasFeeData; + average: GasFeeData; + slow: GasFeeData; +}; const EVM_UNCHAINED_URLS: Record = { - [KnownChainIds.EthereumMainnet]: 'https://api.ethereum.shapeshift.com', - [KnownChainIds.ArbitrumMainnet]: 'https://api.arbitrum.shapeshift.com', - [KnownChainIds.OptimismMainnet]: 'https://api.optimism.shapeshift.com', - [KnownChainIds.PolygonMainnet]: 'https://api.polygon.shapeshift.com', - [KnownChainIds.GnosisMainnet]: 'https://api.gnosis.shapeshift.com', - [KnownChainIds.AvalancheMainnet]: 'https://api.avalanche.shapeshift.com', - [KnownChainIds.BnbSmartChainMainnet]: 'https://api.bnbsmartchain.shapeshift.com', - [KnownChainIds.BaseMainnet]: 'https://api.base.shapeshift.com', - [KnownChainIds.ArbitrumNovaMainnet]: 'https://api.arbitrum-nova.shapeshift.com', -} + [KnownChainIds.EthereumMainnet]: "https://api.ethereum.shapeshift.com", + [KnownChainIds.ArbitrumMainnet]: "https://api.arbitrum.shapeshift.com", + [KnownChainIds.OptimismMainnet]: "https://api.optimism.shapeshift.com", + [KnownChainIds.PolygonMainnet]: "https://api.polygon.shapeshift.com", + [KnownChainIds.GnosisMainnet]: "https://api.gnosis.shapeshift.com", + [KnownChainIds.AvalancheMainnet]: "https://api.avalanche.shapeshift.com", + [KnownChainIds.BnbSmartChainMainnet]: + "https://api.bnbsmartchain.shapeshift.com", + [KnownChainIds.BaseMainnet]: "https://api.base.shapeshift.com", + [KnownChainIds.ArbitrumNovaMainnet]: + "https://api.arbitrum-nova.shapeshift.com", +}; -const fetchGasFees = async (unchainedUrl: string): Promise => { - const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`) +const fetchGasFees = async ( + unchainedUrl: string, +): Promise => { + const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`); if (!response.ok) { - throw new Error(`Failed to fetch gas fees: ${response.statusText}`) + throw new Error(`Failed to fetch gas fees: ${response.statusText}`); } - const data = (await response.json()) as GasFeeDataEstimate + const data = (await response.json()) as GasFeeDataEstimate; return { fast: { gasPrice: data.fast.gasPrice, @@ -62,13 +67,13 @@ const fetchGasFees = async (unchainedUrl: string): Promise = maxFeePerGas: data.slow.maxFeePerGas, maxPriorityFeePerGas: data.slow.maxPriorityFeePerGas, }, - } -} + }; +}; const createMinimalEvmAdapter = (chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + const unchainedUrl = EVM_UNCHAINED_URLS[chainId]; if (!unchainedUrl) { - throw new Error(`No Unchained URL configured for chain ${chainId}`) + throw new Error(`No Unchained URL configured for chain ${chainId}`); } return { @@ -77,78 +82,85 @@ const createMinimalEvmAdapter = (chainId: ChainId) => { getFeeAssetId: () => { switch (chainId) { case KnownChainIds.EthereumMainnet: - return 'eip155:1/slip44:60' + return "eip155:1/slip44:60"; case KnownChainIds.ArbitrumMainnet: - return 'eip155:42161/slip44:60' + return "eip155:42161/slip44:60"; case KnownChainIds.OptimismMainnet: - return 'eip155:10/slip44:60' + return "eip155:10/slip44:60"; case KnownChainIds.PolygonMainnet: - return 'eip155:137/slip44:966' + return "eip155:137/slip44:966"; case KnownChainIds.GnosisMainnet: - return 'eip155:100/slip44:700' + return "eip155:100/slip44:700"; case KnownChainIds.AvalancheMainnet: - return 'eip155:43114/slip44:9005' + return "eip155:43114/slip44:9005"; case KnownChainIds.BnbSmartChainMainnet: - return 'eip155:56/slip44:714' + return "eip155:56/slip44:714"; case KnownChainIds.BaseMainnet: - return 'eip155:8453/slip44:60' + return "eip155:8453/slip44:60"; case KnownChainIds.ArbitrumNovaMainnet: - return 'eip155:42170/slip44:60' + return "eip155:42170/slip44:60"; default: - return `${chainId}/slip44:60` + return `${chainId}/slip44:60`; } }, getDisplayName: () => chainId, - } -} + }; +}; const createStubAdapter = (type: string) => { return () => { throw new Error( `Chain adapter ${type} not implemented in public API. ` + `This swapper requires chain adapter functionality that is not yet available.`, - ) - } -} + ); + }; +}; -export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperDeps => ({ +export const createServerSwapperDeps = ( + assetsById: AssetsByIdPartial, +): SwapperDeps => ({ assetsById, config: getServerConfig(), mixPanel: undefined, - assertGetChainAdapter: createStubAdapter('generic') as unknown as ( + assertGetChainAdapter: createStubAdapter("generic") as unknown as ( chainId: ChainId, ) => ChainAdapter, assertGetEvmChainAdapter: ((chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + const unchainedUrl = EVM_UNCHAINED_URLS[chainId]; if (unchainedUrl) { - return createMinimalEvmAdapter(chainId) + return createMinimalEvmAdapter(chainId); } - throw new Error(`Chain adapter EVM for ${chainId} not implemented in public API.`) + throw new Error( + `Chain adapter EVM for ${chainId} not implemented in public API.`, + ); }) as unknown as (chainId: ChainId) => EvmChainAdapter, - assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as ( + assertGetUtxoChainAdapter: createStubAdapter("UTXO") as unknown as ( chainId: ChainId, ) => UtxoChainAdapter, - assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as ( + assertGetCosmosSdkChainAdapter: createStubAdapter("CosmosSdk") as unknown as ( chainId: ChainId, ) => CosmosSdkChainAdapter, - assertGetSolanaChainAdapter: createStubAdapter('Solana') as unknown as ( + assertGetSolanaChainAdapter: createStubAdapter("Solana") as unknown as ( chainId: ChainId, ) => solana.ChainAdapter, - assertGetTronChainAdapter: createStubAdapter('Tron') as unknown as ( + assertGetTronChainAdapter: createStubAdapter("Tron") as unknown as ( chainId: ChainId, ) => tron.ChainAdapter, - assertGetSuiChainAdapter: createStubAdapter('Sui') as unknown as ( + assertGetSuiChainAdapter: createStubAdapter("Sui") as unknown as ( chainId: ChainId, ) => sui.ChainAdapter, - assertGetNearChainAdapter: createStubAdapter('Near') as unknown as ( + assertGetNearChainAdapter: createStubAdapter("Near") as unknown as ( chainId: ChainId, ) => near.ChainAdapter, - assertGetStarknetChainAdapter: createStubAdapter('Starknet') as unknown as ( + assertGetStarknetChainAdapter: createStubAdapter("Starknet") as unknown as ( chainId: ChainId, ) => starknet.ChainAdapter, + assertGetTonChainAdapter: createStubAdapter("Ton") as unknown as ( + chainId: ChainId, + ) => ton.ChainAdapter, fetchIsSmartContractAddressQuery: () => Promise.resolve(false), -}) +}); From 1c9fd630b53eb0967e144b0c2ee17d5478bd9207 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 18:58:58 +1100 Subject: [PATCH 33/46] fix(public-api): only set initialized flag after successful asset load Prevent initAssets from marking initialization complete when asset loading fails, allowing callers to retry. Now returns rejected promise on error or missing asset file instead of silently succeeding. --- packages/public-api/src/assets.ts | 90 +++++++++++++++++-------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts index 3460e982713..fada7420659 100644 --- a/packages/public-api/src/assets.ts +++ b/packages/public-api/src/assets.ts @@ -1,75 +1,85 @@ -import type { AssetId } from '@shapeshiftoss/caip' -import type { Asset, AssetsById } from '@shapeshiftoss/types' -import { getBaseAsset } from '@shapeshiftoss/utils' -import fs from 'fs' -import path from 'path' +import type { AssetId } from "@shapeshiftoss/caip"; +import type { Asset, AssetsById } from "@shapeshiftoss/types"; +import { getBaseAsset } from "@shapeshiftoss/utils"; +import fs from "fs"; +import path from "path"; -let assetsById: AssetsById = {} -let assetIds: AssetId[] = [] -let assets: Asset[] = [] -let initialized = false +let assetsById: AssetsById = {}; +let assetIds: AssetId[] = []; +let assets: Asset[] = []; +let initialized = false; export const initAssets = (): Promise => { - if (initialized) return Promise.resolve() + if (initialized) return Promise.resolve(); try { // Try to load from the generated asset data file // First check env var, then relative to cwd, then relative to monorepo root const possiblePaths = [ process.env.ASSET_DATA_PATH, - path.join(process.cwd(), 'public/generated/generatedAssetData.json'), - path.join(process.cwd(), '../../public/generated/generatedAssetData.json'), - path.join(process.cwd(), 'generatedAssetData.json'), - ].filter(Boolean) as string[] + path.join(process.cwd(), "public/generated/generatedAssetData.json"), + path.join( + process.cwd(), + "../../public/generated/generatedAssetData.json", + ), + path.join(process.cwd(), "generatedAssetData.json"), + ].filter(Boolean) as string[]; - let assetDataPath: string | undefined + let assetDataPath: string | undefined; for (const p of possiblePaths) { if (fs.existsSync(p)) { - assetDataPath = p - break + assetDataPath = p; + break; } } if (!assetDataPath) { - console.warn('Asset data file not found in any of the expected locations:', possiblePaths) - initialized = true - return Promise.resolve() + const error = new Error( + `Asset data file not found in any of the expected locations: ${possiblePaths.join( + ", ", + )}`, + ); + console.warn(error.message); + return Promise.reject(error); } - const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, 'utf8')) - const localAssetData = assetDataJson.byId - const sortedAssetIds = assetDataJson.ids as AssetId[] + const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, "utf8")); + const localAssetData = assetDataJson.byId; + const sortedAssetIds = assetDataJson.ids as AssetId[]; // Enrich assets with chain-level data - const enrichedAssetsById: AssetsById = {} + const enrichedAssetsById: AssetsById = {}; for (const assetId of sortedAssetIds) { - const asset = localAssetData[assetId] + const asset = localAssetData[assetId]; if (asset) { - const baseAsset = getBaseAsset(asset.chainId) + const baseAsset = getBaseAsset(asset.chainId); enrichedAssetsById[assetId] = { ...asset, networkName: baseAsset?.networkName, explorer: baseAsset?.explorer, explorerAddressLink: baseAsset?.explorerAddressLink, explorerTxLink: baseAsset?.explorerTxLink, - } + }; } } - assetsById = enrichedAssetsById - assetIds = sortedAssetIds - assets = sortedAssetIds.map((id: AssetId) => enrichedAssetsById[id]).filter(Boolean) as Asset[] + assetsById = enrichedAssetsById; + assetIds = sortedAssetIds; + assets = sortedAssetIds + .map((id: AssetId) => enrichedAssetsById[id]) + .filter(Boolean) as Asset[]; - console.log(`Loaded ${assetIds.length} assets from ${assetDataPath}`) + console.log(`Loaded ${assetIds.length} assets from ${assetDataPath}`); + initialized = true; + return Promise.resolve(); } catch (error) { - console.error('Failed to load assets:', error) + console.error("Failed to load assets:", error); + return Promise.reject(error); } +}; - initialized = true - return Promise.resolve() -} - -export const getAssetsById = (): AssetsById => assetsById -export const getAssetIds = (): AssetId[] => assetIds -export const getAllAssets = (): Asset[] => assets -export const getAsset = (assetId: AssetId): Asset | undefined => assetsById[assetId] +export const getAssetsById = (): AssetsById => assetsById; +export const getAssetIds = (): AssetId[] => assetIds; +export const getAllAssets = (): Asset[] => assets; +export const getAsset = (assetId: AssetId): Asset | undefined => + assetsById[assetId]; From a0fd0c6066a7f3382accfae2213cbf5a5a11bfc0 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:01:24 +1100 Subject: [PATCH 34/46] fix(public-api): return 400 for malformed URL encoding in getAssetById Wrap decodeURIComponent in try/catch to handle URIError from invalid percent-encoding sequences, returning a 400 with descriptive error instead of letting it bubble up as a 500. --- packages/public-api/src/routes/assets.ts | 109 ++++++++++++++--------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/packages/public-api/src/routes/assets.ts b/packages/public-api/src/routes/assets.ts index 83aea66732f..624d04dcabd 100644 --- a/packages/public-api/src/routes/assets.ts +++ b/packages/public-api/src/routes/assets.ts @@ -1,92 +1,115 @@ -import type { Request, Response } from 'express' -import { z } from 'zod' +import type { Request, Response } from "express"; +import { z } from "zod"; -import { getAllAssets, getAsset, getAssetIds } from '../assets' -import type { AssetsResponse, ErrorResponse } from '../types' +import { getAllAssets, getAsset, getAssetIds } from "../assets"; +import type { AssetsResponse, ErrorResponse } from "../types"; // Request validation schema for single asset export const AssetRequestSchema = z.object({ - assetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), -}) + assetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), +}); // Request validation schema for filtered list export const AssetsListRequestSchema = z.object({ - chainId: z.string().optional().openapi({ example: 'eip155:1' }), - limit: z.coerce.number().min(1).max(1000).optional().default(100).openapi({ example: 100 }), - offset: z.coerce.number().min(0).optional().default(0).openapi({ example: 0 }), -}) + chainId: z.string().optional().openapi({ example: "eip155:1" }), + limit: z.coerce + .number() + .min(1) + .max(1000) + .optional() + .default(100) + .openapi({ example: 100 }), + offset: z.coerce + .number() + .min(0) + .optional() + .default(0) + .openapi({ example: 0 }), +}); export const getAssets = (req: Request, res: Response): void => { try { - const parseResult = AssetsListRequestSchema.safeParse(req.query) + const parseResult = AssetsListRequestSchema.safeParse(req.query); if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: 'Invalid request parameters', + error: "Invalid request parameters", details: parseResult.error.errors, - } - res.status(400).json(errorResponse) - return + }; + res.status(400).json(errorResponse); + return; } - const { chainId, limit, offset } = parseResult.data + const { chainId, limit, offset } = parseResult.data; - let assets = getAllAssets() + let assets = getAllAssets(); // Filter by chain if specified if (chainId) { - assets = assets.filter(asset => asset.chainId === chainId) + assets = assets.filter((asset) => asset.chainId === chainId); } // Apply pagination - const paginatedAssets = assets.slice(offset, offset + limit) + const paginatedAssets = assets.slice(offset, offset + limit); const response: AssetsResponse = { assets: paginatedAssets, timestamp: Date.now(), - } + }; - res.json(response) + res.json(response); } catch (error) { - console.error('Error in getAssets:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + console.error("Error in getAssets:", error); + res.status(500).json({ error: "Internal server error" } as ErrorResponse); } -} +}; export const getAssetById = (req: Request, res: Response): void => { try { - const parseResult = AssetRequestSchema.safeParse(req.params) + const parseResult = AssetRequestSchema.safeParse(req.params); if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: 'Invalid request parameters', + error: "Invalid request parameters", details: parseResult.error.errors, - } - res.status(400).json(errorResponse) - return + }; + res.status(400).json(errorResponse); + return; } - const { assetId } = parseResult.data + const { assetId } = parseResult.data; // URL decode the assetId since it contains special characters - const decodedAssetId = decodeURIComponent(assetId) - const asset = getAsset(decodedAssetId) + let decodedAssetId: string; + try { + decodedAssetId = decodeURIComponent(assetId); + } catch { + const errorResponse: ErrorResponse = { + error: "Invalid URL encoding for assetId", + details: { assetId }, + }; + res.status(400).json(errorResponse); + return; + } + const asset = getAsset(decodedAssetId); if (!asset) { - res.status(404).json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse) - return + res + .status(404) + .json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse); + return; } - res.json(asset) + res.json(asset); } catch (error) { - console.error('Error in getAssetById:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + console.error("Error in getAssetById:", error); + res.status(500).json({ error: "Internal server error" } as ErrorResponse); } -} +}; export const getAssetCount = (_req: Request, res: Response): void => { try { - const count = getAssetIds().length - res.json({ count, timestamp: Date.now() }) + const count = getAssetIds().length; + res.json({ count, timestamp: Date.now() }); } catch (error) { - console.error('Error in getAssetCount:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + console.error("Error in getAssetCount:", error); + res.status(500).json({ error: "Internal server error" } as ErrorResponse); } -} +}; From 40367422f5fa5285b9e1fe558cf4e69dbf4d7a2f Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:09:51 +1100 Subject: [PATCH 35/46] fix(public-api): use isNativeEvmAsset for robust native asset detection Replace inline `includes('slip44:60')` check with the existing `isNativeEvmAsset` helper from @shapeshiftoss/swapper for detecting native EVM assets in approval logic. The previous check only matched Ethereum; this correctly handles all EVM chains. Co-Authored-By: Claude --- packages/public-api/src/routes/quote.ts | 26 ++++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index 8f255f59571..d7dec4f5bf5 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -5,6 +5,7 @@ import type { TradeQuote, TradeQuoteStep, } from '@shapeshiftoss/swapper' +import { isNativeEvmAsset } from '@shapeshiftoss/swapper' import type { Request, Response } from 'express' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -27,10 +28,9 @@ const getSwapperModule = async () => { // Request validation schema - swapperName is string, validated later export const QuoteRequestSchema = z.object({ sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), - buyAssetId: z - .string() - .min(1) - .openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), + buyAssetId: z.string().min(1).openapi({ + example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), receiveAddress: z .string() @@ -90,27 +90,21 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction return undefined } -// Helper to build approval info const buildApprovalInfo = (step: TradeQuoteStep, _sellAssetId: string): ApprovalInfo => { const { chainNamespace } = fromChainId(step.sellAsset.chainId) - // Only EVM tokens need approval if (chainNamespace !== 'eip155') { return { isRequired: false, spender: '' } } - // Native assets don't need approval - if (step.sellAsset.assetId.includes('slip44:60')) { + if (isNativeEvmAsset(step.sellAsset.assetId)) { return { isRequired: false, spender: '' } } - // If there's an allowance contract, approval may be needed if (step.allowanceContract) { return { - isRequired: true, // Consumer should check current allowance + isRequired: true, spender: step.allowanceContract, - // Approval transaction data - consumer builds this themselves - // or we could provide it if needed } } @@ -170,7 +164,9 @@ export const getQuote = async (req: Request, res: Response): Promise => { // Validate swapper exists const swapper = swappers[validSwapperName] if (!swapper) { - res.status(400).json({ error: `Swapper not available: ${swapperName}` } as ErrorResponse) + res.status(400).json({ + error: `Swapper not available: ${swapperName}`, + } as ErrorResponse) return } @@ -225,7 +221,9 @@ export const getQuote = async (req: Request, res: Response): Promise => { ) if (!result) { - res.status(404).json({ error: 'No quote available from this swapper' } as ErrorResponse) + res.status(404).json({ + error: 'No quote available from this swapper', + } as ErrorResponse) return } From 3b144ea989d7885701ea1b12d79babd11aa402b1 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:23:28 +1100 Subject: [PATCH 36/46] fix(public-api): add missing affiliateBps and networkFeeCryptoBaseUnit to RateResponseSchema Co-Authored-By: Claude --- packages/public-api/src/docs/openapi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index 12f95da6d51..83a095b456e 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -32,7 +32,9 @@ const AssetSchema = registry.register( symbol: z.string().openapi({ example: 'ETH' }), precision: z.number().openapi({ example: 18 }), color: z.string().openapi({ example: '#5C6BC0' }), - icon: z.string().openapi({ example: 'https://assets.coincap.io/assets/icons/eth@2x.png' }), + icon: z.string().openapi({ + example: 'https://assets.coincap.io/assets/icons/eth@2x.png', + }), explorer: z.string().openapi({ example: 'https://etherscan.io' }), explorerAddressLink: z.string().openapi({ example: 'https://etherscan.io/address/' }), explorerTxLink: z.string().openapi({ example: 'https://etherscan.io/tx/' }), @@ -95,6 +97,8 @@ const RateResponseSchema = registry.register( steps: z.number(), estimatedExecutionTimeMs: z.number().optional(), priceImpactPercentageDecimal: z.string().optional(), + affiliateBps: z.string(), + networkFeeCryptoBaseUnit: z.string().optional(), error: z .object({ code: z.string(), From 9a8cf1622bbc9df9b74ae4c8b2a04152a741d560 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:24:23 +1100 Subject: [PATCH 37/46] chore(public-api): lint fixes Co-Authored-By: Claude --- packages/public-api/src/assets.ts | 90 +++++++--------- packages/public-api/src/routes/assets.ts | 109 +++++++++---------- packages/public-api/src/swapperDeps.ts | 130 +++++++++++------------ 3 files changed, 150 insertions(+), 179 deletions(-) diff --git a/packages/public-api/src/assets.ts b/packages/public-api/src/assets.ts index fada7420659..643f8b1711b 100644 --- a/packages/public-api/src/assets.ts +++ b/packages/public-api/src/assets.ts @@ -1,85 +1,77 @@ -import type { AssetId } from "@shapeshiftoss/caip"; -import type { Asset, AssetsById } from "@shapeshiftoss/types"; -import { getBaseAsset } from "@shapeshiftoss/utils"; -import fs from "fs"; -import path from "path"; +import type { AssetId } from '@shapeshiftoss/caip' +import type { Asset, AssetsById } from '@shapeshiftoss/types' +import { getBaseAsset } from '@shapeshiftoss/utils' +import fs from 'fs' +import path from 'path' -let assetsById: AssetsById = {}; -let assetIds: AssetId[] = []; -let assets: Asset[] = []; -let initialized = false; +let assetsById: AssetsById = {} +let assetIds: AssetId[] = [] +let assets: Asset[] = [] +let initialized = false export const initAssets = (): Promise => { - if (initialized) return Promise.resolve(); + if (initialized) return Promise.resolve() try { // Try to load from the generated asset data file // First check env var, then relative to cwd, then relative to monorepo root const possiblePaths = [ process.env.ASSET_DATA_PATH, - path.join(process.cwd(), "public/generated/generatedAssetData.json"), - path.join( - process.cwd(), - "../../public/generated/generatedAssetData.json", - ), - path.join(process.cwd(), "generatedAssetData.json"), - ].filter(Boolean) as string[]; + path.join(process.cwd(), 'public/generated/generatedAssetData.json'), + path.join(process.cwd(), '../../public/generated/generatedAssetData.json'), + path.join(process.cwd(), 'generatedAssetData.json'), + ].filter(Boolean) as string[] - let assetDataPath: string | undefined; + let assetDataPath: string | undefined for (const p of possiblePaths) { if (fs.existsSync(p)) { - assetDataPath = p; - break; + assetDataPath = p + break } } if (!assetDataPath) { const error = new Error( - `Asset data file not found in any of the expected locations: ${possiblePaths.join( - ", ", - )}`, - ); - console.warn(error.message); - return Promise.reject(error); + `Asset data file not found in any of the expected locations: ${possiblePaths.join(', ')}`, + ) + console.warn(error.message) + return Promise.reject(error) } - const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, "utf8")); - const localAssetData = assetDataJson.byId; - const sortedAssetIds = assetDataJson.ids as AssetId[]; + const assetDataJson = JSON.parse(fs.readFileSync(assetDataPath, 'utf8')) + const localAssetData = assetDataJson.byId + const sortedAssetIds = assetDataJson.ids as AssetId[] // Enrich assets with chain-level data - const enrichedAssetsById: AssetsById = {}; + const enrichedAssetsById: AssetsById = {} for (const assetId of sortedAssetIds) { - const asset = localAssetData[assetId]; + const asset = localAssetData[assetId] if (asset) { - const baseAsset = getBaseAsset(asset.chainId); + const baseAsset = getBaseAsset(asset.chainId) enrichedAssetsById[assetId] = { ...asset, networkName: baseAsset?.networkName, explorer: baseAsset?.explorer, explorerAddressLink: baseAsset?.explorerAddressLink, explorerTxLink: baseAsset?.explorerTxLink, - }; + } } } - assetsById = enrichedAssetsById; - assetIds = sortedAssetIds; - assets = sortedAssetIds - .map((id: AssetId) => enrichedAssetsById[id]) - .filter(Boolean) as Asset[]; + assetsById = enrichedAssetsById + assetIds = sortedAssetIds + assets = sortedAssetIds.map((id: AssetId) => enrichedAssetsById[id]).filter(Boolean) as Asset[] - console.log(`Loaded ${assetIds.length} assets from ${assetDataPath}`); - initialized = true; - return Promise.resolve(); + console.log(`Loaded ${assetIds.length} assets from ${assetDataPath}`) + initialized = true + return Promise.resolve() } catch (error) { - console.error("Failed to load assets:", error); - return Promise.reject(error); + console.error('Failed to load assets:', error) + return Promise.reject(error) } -}; +} -export const getAssetsById = (): AssetsById => assetsById; -export const getAssetIds = (): AssetId[] => assetIds; -export const getAllAssets = (): Asset[] => assets; -export const getAsset = (assetId: AssetId): Asset | undefined => - assetsById[assetId]; +export const getAssetsById = (): AssetsById => assetsById +export const getAssetIds = (): AssetId[] => assetIds +export const getAllAssets = (): Asset[] => assets +export const getAsset = (assetId: AssetId): Asset | undefined => assetsById[assetId] diff --git a/packages/public-api/src/routes/assets.ts b/packages/public-api/src/routes/assets.ts index 624d04dcabd..4c69e3515ae 100644 --- a/packages/public-api/src/routes/assets.ts +++ b/packages/public-api/src/routes/assets.ts @@ -1,115 +1,102 @@ -import type { Request, Response } from "express"; -import { z } from "zod"; +import type { Request, Response } from 'express' +import { z } from 'zod' -import { getAllAssets, getAsset, getAssetIds } from "../assets"; -import type { AssetsResponse, ErrorResponse } from "../types"; +import { getAllAssets, getAsset, getAssetIds } from '../assets' +import type { AssetsResponse, ErrorResponse } from '../types' // Request validation schema for single asset export const AssetRequestSchema = z.object({ - assetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), -}); + assetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), +}) // Request validation schema for filtered list export const AssetsListRequestSchema = z.object({ - chainId: z.string().optional().openapi({ example: "eip155:1" }), - limit: z.coerce - .number() - .min(1) - .max(1000) - .optional() - .default(100) - .openapi({ example: 100 }), - offset: z.coerce - .number() - .min(0) - .optional() - .default(0) - .openapi({ example: 0 }), -}); + chainId: z.string().optional().openapi({ example: 'eip155:1' }), + limit: z.coerce.number().min(1).max(1000).optional().default(100).openapi({ example: 100 }), + offset: z.coerce.number().min(0).optional().default(0).openapi({ example: 0 }), +}) export const getAssets = (req: Request, res: Response): void => { try { - const parseResult = AssetsListRequestSchema.safeParse(req.query); + const parseResult = AssetsListRequestSchema.safeParse(req.query) if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: "Invalid request parameters", + error: 'Invalid request parameters', details: parseResult.error.errors, - }; - res.status(400).json(errorResponse); - return; + } + res.status(400).json(errorResponse) + return } - const { chainId, limit, offset } = parseResult.data; + const { chainId, limit, offset } = parseResult.data - let assets = getAllAssets(); + let assets = getAllAssets() // Filter by chain if specified if (chainId) { - assets = assets.filter((asset) => asset.chainId === chainId); + assets = assets.filter(asset => asset.chainId === chainId) } // Apply pagination - const paginatedAssets = assets.slice(offset, offset + limit); + const paginatedAssets = assets.slice(offset, offset + limit) const response: AssetsResponse = { assets: paginatedAssets, timestamp: Date.now(), - }; + } - res.json(response); + res.json(response) } catch (error) { - console.error("Error in getAssets:", error); - res.status(500).json({ error: "Internal server error" } as ErrorResponse); + console.error('Error in getAssets:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) } -}; +} export const getAssetById = (req: Request, res: Response): void => { try { - const parseResult = AssetRequestSchema.safeParse(req.params); + const parseResult = AssetRequestSchema.safeParse(req.params) if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: "Invalid request parameters", + error: 'Invalid request parameters', details: parseResult.error.errors, - }; - res.status(400).json(errorResponse); - return; + } + res.status(400).json(errorResponse) + return } - const { assetId } = parseResult.data; + const { assetId } = parseResult.data // URL decode the assetId since it contains special characters - let decodedAssetId: string; + let decodedAssetId: string try { - decodedAssetId = decodeURIComponent(assetId); + decodedAssetId = decodeURIComponent(assetId) } catch { const errorResponse: ErrorResponse = { - error: "Invalid URL encoding for assetId", + error: 'Invalid URL encoding for assetId', details: { assetId }, - }; - res.status(400).json(errorResponse); - return; + } + res.status(400).json(errorResponse) + return } - const asset = getAsset(decodedAssetId); + const asset = getAsset(decodedAssetId) if (!asset) { - res - .status(404) - .json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse); - return; + res.status(404).json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse) + return } - res.json(asset); + res.json(asset) } catch (error) { - console.error("Error in getAssetById:", error); - res.status(500).json({ error: "Internal server error" } as ErrorResponse); + console.error('Error in getAssetById:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) } -}; +} export const getAssetCount = (_req: Request, res: Response): void => { try { - const count = getAssetIds().length; - res.json({ count, timestamp: Date.now() }); + const count = getAssetIds().length + res.json({ count, timestamp: Date.now() }) } catch (error) { - console.error("Error in getAssetCount:", error); - res.status(500).json({ error: "Internal server error" } as ErrorResponse); + console.error('Error in getAssetCount:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) } -}; +} diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index e61e9ed86b6..e6f235b8498 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -1,4 +1,4 @@ -import type { ChainId } from "@shapeshiftoss/caip"; +import type { ChainId } from '@shapeshiftoss/caip' import type { ChainAdapter, CosmosSdkChainAdapter, @@ -10,47 +10,43 @@ import type { ton, tron, UtxoChainAdapter, -} from "@shapeshiftoss/chain-adapters"; -import type { SwapperDeps } from "@shapeshiftoss/swapper"; -import type { AssetsByIdPartial } from "@shapeshiftoss/types"; -import { KnownChainIds } from "@shapeshiftoss/types"; +} from '@shapeshiftoss/chain-adapters' +import type { SwapperDeps } from '@shapeshiftoss/swapper' +import type { AssetsByIdPartial } from '@shapeshiftoss/types' +import { KnownChainIds } from '@shapeshiftoss/types' -import { getServerConfig } from "./config"; +import { getServerConfig } from './config' type GasFeeData = { - gasPrice: string; - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; -}; + gasPrice: string + maxFeePerGas?: string + maxPriorityFeePerGas?: string +} type GasFeeDataEstimate = { - fast: GasFeeData; - average: GasFeeData; - slow: GasFeeData; -}; + fast: GasFeeData + average: GasFeeData + slow: GasFeeData +} const EVM_UNCHAINED_URLS: Record = { - [KnownChainIds.EthereumMainnet]: "https://api.ethereum.shapeshift.com", - [KnownChainIds.ArbitrumMainnet]: "https://api.arbitrum.shapeshift.com", - [KnownChainIds.OptimismMainnet]: "https://api.optimism.shapeshift.com", - [KnownChainIds.PolygonMainnet]: "https://api.polygon.shapeshift.com", - [KnownChainIds.GnosisMainnet]: "https://api.gnosis.shapeshift.com", - [KnownChainIds.AvalancheMainnet]: "https://api.avalanche.shapeshift.com", - [KnownChainIds.BnbSmartChainMainnet]: - "https://api.bnbsmartchain.shapeshift.com", - [KnownChainIds.BaseMainnet]: "https://api.base.shapeshift.com", - [KnownChainIds.ArbitrumNovaMainnet]: - "https://api.arbitrum-nova.shapeshift.com", -}; + [KnownChainIds.EthereumMainnet]: 'https://api.ethereum.shapeshift.com', + [KnownChainIds.ArbitrumMainnet]: 'https://api.arbitrum.shapeshift.com', + [KnownChainIds.OptimismMainnet]: 'https://api.optimism.shapeshift.com', + [KnownChainIds.PolygonMainnet]: 'https://api.polygon.shapeshift.com', + [KnownChainIds.GnosisMainnet]: 'https://api.gnosis.shapeshift.com', + [KnownChainIds.AvalancheMainnet]: 'https://api.avalanche.shapeshift.com', + [KnownChainIds.BnbSmartChainMainnet]: 'https://api.bnbsmartchain.shapeshift.com', + [KnownChainIds.BaseMainnet]: 'https://api.base.shapeshift.com', + [KnownChainIds.ArbitrumNovaMainnet]: 'https://api.arbitrum-nova.shapeshift.com', +} -const fetchGasFees = async ( - unchainedUrl: string, -): Promise => { - const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`); +const fetchGasFees = async (unchainedUrl: string): Promise => { + const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`) if (!response.ok) { - throw new Error(`Failed to fetch gas fees: ${response.statusText}`); + throw new Error(`Failed to fetch gas fees: ${response.statusText}`) } - const data = (await response.json()) as GasFeeDataEstimate; + const data = (await response.json()) as GasFeeDataEstimate return { fast: { gasPrice: data.fast.gasPrice, @@ -67,13 +63,13 @@ const fetchGasFees = async ( maxFeePerGas: data.slow.maxFeePerGas, maxPriorityFeePerGas: data.slow.maxPriorityFeePerGas, }, - }; -}; + } +} const createMinimalEvmAdapter = (chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId]; + const unchainedUrl = EVM_UNCHAINED_URLS[chainId] if (!unchainedUrl) { - throw new Error(`No Unchained URL configured for chain ${chainId}`); + throw new Error(`No Unchained URL configured for chain ${chainId}`) } return { @@ -82,85 +78,81 @@ const createMinimalEvmAdapter = (chainId: ChainId) => { getFeeAssetId: () => { switch (chainId) { case KnownChainIds.EthereumMainnet: - return "eip155:1/slip44:60"; + return 'eip155:1/slip44:60' case KnownChainIds.ArbitrumMainnet: - return "eip155:42161/slip44:60"; + return 'eip155:42161/slip44:60' case KnownChainIds.OptimismMainnet: - return "eip155:10/slip44:60"; + return 'eip155:10/slip44:60' case KnownChainIds.PolygonMainnet: - return "eip155:137/slip44:966"; + return 'eip155:137/slip44:966' case KnownChainIds.GnosisMainnet: - return "eip155:100/slip44:700"; + return 'eip155:100/slip44:700' case KnownChainIds.AvalancheMainnet: - return "eip155:43114/slip44:9005"; + return 'eip155:43114/slip44:9005' case KnownChainIds.BnbSmartChainMainnet: - return "eip155:56/slip44:714"; + return 'eip155:56/slip44:714' case KnownChainIds.BaseMainnet: - return "eip155:8453/slip44:60"; + return 'eip155:8453/slip44:60' case KnownChainIds.ArbitrumNovaMainnet: - return "eip155:42170/slip44:60"; + return 'eip155:42170/slip44:60' default: - return `${chainId}/slip44:60`; + return `${chainId}/slip44:60` } }, getDisplayName: () => chainId, - }; -}; + } +} const createStubAdapter = (type: string) => { return () => { throw new Error( `Chain adapter ${type} not implemented in public API. ` + `This swapper requires chain adapter functionality that is not yet available.`, - ); - }; -}; + ) + } +} -export const createServerSwapperDeps = ( - assetsById: AssetsByIdPartial, -): SwapperDeps => ({ +export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperDeps => ({ assetsById, config: getServerConfig(), mixPanel: undefined, - assertGetChainAdapter: createStubAdapter("generic") as unknown as ( + assertGetChainAdapter: createStubAdapter('generic') as unknown as ( chainId: ChainId, ) => ChainAdapter, assertGetEvmChainAdapter: ((chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId]; + const unchainedUrl = EVM_UNCHAINED_URLS[chainId] if (unchainedUrl) { - return createMinimalEvmAdapter(chainId); + return createMinimalEvmAdapter(chainId) } - throw new Error( - `Chain adapter EVM for ${chainId} not implemented in public API.`, - ); + throw new Error(`Chain adapter EVM for ${chainId} not implemented in public API.`) }) as unknown as (chainId: ChainId) => EvmChainAdapter, - assertGetUtxoChainAdapter: createStubAdapter("UTXO") as unknown as ( + assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as ( chainId: ChainId, ) => UtxoChainAdapter, - assertGetCosmosSdkChainAdapter: createStubAdapter("CosmosSdk") as unknown as ( + assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as ( chainId: ChainId, ) => CosmosSdkChainAdapter, - assertGetSolanaChainAdapter: createStubAdapter("Solana") as unknown as ( + assertGetSolanaChainAdapter: createStubAdapter('Solana') as unknown as ( chainId: ChainId, ) => solana.ChainAdapter, - assertGetTronChainAdapter: createStubAdapter("Tron") as unknown as ( + assertGetTronChainAdapter: createStubAdapter('Tron') as unknown as ( chainId: ChainId, ) => tron.ChainAdapter, - assertGetSuiChainAdapter: createStubAdapter("Sui") as unknown as ( + assertGetSuiChainAdapter: createStubAdapter('Sui') as unknown as ( chainId: ChainId, ) => sui.ChainAdapter, - assertGetNearChainAdapter: createStubAdapter("Near") as unknown as ( + assertGetNearChainAdapter: createStubAdapter('Near') as unknown as ( chainId: ChainId, ) => near.ChainAdapter, - assertGetStarknetChainAdapter: createStubAdapter("Starknet") as unknown as ( + assertGetStarknetChainAdapter: createStubAdapter('Starknet') as unknown as ( chainId: ChainId, ) => starknet.ChainAdapter, - assertGetTonChainAdapter: createStubAdapter("Ton") as unknown as ( + assertGetTonChainAdapter: createStubAdapter('Ton') as unknown as ( chainId: ChainId, ) => ton.ChainAdapter, fetchIsSmartContractAddressQuery: () => Promise.resolve(false), -}); +}) From 85f904d445939bb44e8abd06d2ec653874f4b3b4 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:46:24 +1100 Subject: [PATCH 38/46] fix(public-api): coerce boolean and number query params from strings Query parameters are always received as strings in Express. The Zod schemas for rates and quote endpoints expected actual boolean/number types, causing validation errors when allowMultiHop was passed as "true" or "false" string from the docs example. - Create shared booleanFromString utility to preprocess string booleans - Update rates.ts to use booleanFromString for allowMultiHop - Update quote.ts to use booleanFromString and z.coerce.number() Co-Authored-By: Claude --- packages/public-api/src/lib/zod.ts | 6 + packages/public-api/src/routes/quote.ts | 228 +++++++++++-------- packages/public-api/src/routes/rates.ts | 278 +++++++++++++----------- 3 files changed, 293 insertions(+), 219 deletions(-) create mode 100644 packages/public-api/src/lib/zod.ts diff --git a/packages/public-api/src/lib/zod.ts b/packages/public-api/src/lib/zod.ts new file mode 100644 index 00000000000..b08acd560b7 --- /dev/null +++ b/packages/public-api/src/lib/zod.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const booleanFromString = z.preprocess( + (val) => (val === "true" ? true : val === "false" ? false : val), + z.boolean(), +); diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index d7dec4f5bf5..e4d6677c48e 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -1,53 +1,75 @@ -import { fromChainId } from '@shapeshiftoss/caip' +import { fromChainId } from "@shapeshiftoss/caip"; import type { GetTradeQuoteInputWithWallet, SwapperName, TradeQuote, TradeQuoteStep, -} from '@shapeshiftoss/swapper' -import { isNativeEvmAsset } from '@shapeshiftoss/swapper' -import type { Request, Response } from 'express' -import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' - -import { getAsset, getAssetsById } from '../assets' -import { DEFAULT_AFFILIATE_BPS } from '../config' -import { createServerSwapperDeps } from '../swapperDeps' -import type { ApiQuoteStep, ApprovalInfo, ErrorResponse, QuoteResponse } from '../types' +} from "@shapeshiftoss/swapper"; +import { isNativeEvmAsset } from "@shapeshiftoss/swapper"; +import type { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; + +import { getAsset, getAssetsById } from "../assets"; +import { DEFAULT_AFFILIATE_BPS } from "../config"; +import { booleanFromString } from "../lib/zod"; +import { createServerSwapperDeps } from "../swapperDeps"; +import type { + ApiQuoteStep, + ApprovalInfo, + ErrorResponse, + QuoteResponse, +} from "../types"; // Lazy load swapper to avoid import issues at module load time -let swapperModule: Awaited> | null = null -const importSwapperModule = () => import('@shapeshiftoss/swapper') +let swapperModule: Awaited> | null = + null; +const importSwapperModule = () => import("@shapeshiftoss/swapper"); const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await importSwapperModule() + swapperModule = await importSwapperModule(); } - return swapperModule -} + return swapperModule; +}; // Request validation schema - swapperName is string, validated later export const QuoteRequestSchema = z.object({ - sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), + sellAssetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), buyAssetId: z.string().min(1).openapi({ - example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + example: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", }), - sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), + sellAmountCryptoBaseUnit: z + .string() + .min(1) + .openapi({ example: "1000000000000000000" }), receiveAddress: z .string() .min(1) - .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + .openapi({ example: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28" }), sendAddress: z .string() .optional() - .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), - swapperName: z.string().min(1).openapi({ example: '0x' }), - slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), - allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), - accountNumber: z.number().optional().default(0).openapi({ example: 0 }), -}) + .openapi({ example: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28" }), + swapperName: z.string().min(1).openapi({ example: "0x" }), + slippageTolerancePercentageDecimal: z + .string() + .optional() + .openapi({ example: "0.01" }), + allowMultiHop: booleanFromString + .optional() + .default(true) + .openapi({ example: true }), + accountNumber: z.coerce + .number() + .optional() + .default(0) + .openapi({ example: 0 }), +}); // Helper to extract transaction data from quote step -const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transactionData'] => { +const extractTransactionData = ( + step: TradeQuoteStep, +): ApiQuoteStep["transactionData"] => { // Check for various swapper-specific transaction metadata if (step.zrxTransactionMetadata) { return { @@ -55,7 +77,7 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction data: step.zrxTransactionMetadata.data, value: step.zrxTransactionMetadata.value, gasLimit: step.zrxTransactionMetadata.gas, - } + }; } if (step.portalsTransactionMetadata) { @@ -64,7 +86,7 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction data: step.portalsTransactionMetadata.data, value: step.portalsTransactionMetadata.value, gasLimit: step.portalsTransactionMetadata.gasLimit, - } + }; } if (step.bebopTransactionMetadata) { @@ -73,7 +95,7 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction data: step.bebopTransactionMetadata.data, value: step.bebopTransactionMetadata.value, gasLimit: step.bebopTransactionMetadata.gas, - } + }; } if (step.butterSwapTransactionMetadata) { @@ -82,34 +104,37 @@ const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transaction data: step.butterSwapTransactionMetadata.data, value: step.butterSwapTransactionMetadata.value, gasLimit: step.butterSwapTransactionMetadata.gasLimit, - } + }; } // For THORChain/MAYAChain and other swappers, transaction is built differently // The consumer will need to use the quote data to build the transaction - return undefined -} + return undefined; +}; -const buildApprovalInfo = (step: TradeQuoteStep, _sellAssetId: string): ApprovalInfo => { - const { chainNamespace } = fromChainId(step.sellAsset.chainId) +const buildApprovalInfo = ( + step: TradeQuoteStep, + _sellAssetId: string, +): ApprovalInfo => { + const { chainNamespace } = fromChainId(step.sellAsset.chainId); - if (chainNamespace !== 'eip155') { - return { isRequired: false, spender: '' } + if (chainNamespace !== "eip155") { + return { isRequired: false, spender: "" }; } if (isNativeEvmAsset(step.sellAsset.assetId)) { - return { isRequired: false, spender: '' } + return { isRequired: false, spender: "" }; } if (step.allowanceContract) { return { isRequired: true, spender: step.allowanceContract, - } + }; } - return { isRequired: false, spender: '' } -} + return { isRequired: false, spender: "" }; +}; // Transform quote step to API format const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ @@ -121,19 +146,19 @@ const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ estimatedExecutionTimeMs: step.estimatedExecutionTimeMs, source: step.source, transactionData: extractTransactionData(step), -}) +}); export const getQuote = async (req: Request, res: Response): Promise => { try { // Parse and validate request - const parseResult = QuoteRequestSchema.safeParse(req.body) + const parseResult = QuoteRequestSchema.safeParse(req.body); if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: 'Invalid request parameters', + error: "Invalid request parameters", details: parseResult.error.errors, - } - res.status(400).json(errorResponse) - return + }; + res.status(400).json(errorResponse); + return; } const { @@ -146,53 +171,64 @@ export const getQuote = async (req: Request, res: Response): Promise => { slippageTolerancePercentageDecimal, allowMultiHop, accountNumber, - } = parseResult.data + } = parseResult.data; // Lazy load swapper module - const { getTradeQuotes, swappers, SwapperName, getDefaultSlippageDecimalPercentageForSwapper } = - await getSwapperModule() + const { + getTradeQuotes, + swappers, + SwapperName, + getDefaultSlippageDecimalPercentageForSwapper, + } = await getSwapperModule(); // Validate swapper name - const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as - | SwapperName - | undefined + const validSwapperName = Object.values(SwapperName).find( + (v) => v === swapperName, + ) as SwapperName | undefined; if (!validSwapperName) { - res.status(400).json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse) - return + res + .status(400) + .json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse); + return; } // Validate swapper exists - const swapper = swappers[validSwapperName] + const swapper = swappers[validSwapperName]; if (!swapper) { res.status(400).json({ error: `Swapper not available: ${swapperName}`, - } as ErrorResponse) - return + } as ErrorResponse); + return; } // Get assets - const sellAsset = getAsset(sellAssetId) - const buyAsset = getAsset(buyAssetId) + const sellAsset = getAsset(sellAssetId); + const buyAsset = getAsset(buyAssetId); if (!sellAsset) { - res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) - return + res + .status(400) + .json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse); + return; } if (!buyAsset) { - res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) - return + res + .status(400) + .json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse); + return; } // Create swapper dependencies - const deps = createServerSwapperDeps(getAssetsById()) + const deps = createServerSwapperDeps(getAssetsById()); // Get default slippage if not provided - let slippage = slippageTolerancePercentageDecimal + let slippage = slippageTolerancePercentageDecimal; if (!slippage) { try { - slippage = getDefaultSlippageDecimalPercentageForSwapper(validSwapperName) + slippage = + getDefaultSlippageDecimalPercentageForSwapper(validSwapperName); } catch { - slippage = '0.01' // 1% default fallback + slippage = "0.01"; // 1% default fallback } } @@ -207,52 +243,52 @@ export const getQuote = async (req: Request, res: Response): Promise => { receiveAddress, sendAddress, accountNumber, - quoteOrRate: 'quote' as const, + quoteOrRate: "quote" as const, chainId: sellAsset.chainId, // EVM-specific fields supportsEIP1559: true, - } + }; // Fetch quote const result = await getTradeQuotes( quoteInput as GetTradeQuoteInputWithWallet, validSwapperName, deps, - ) + ); if (!result) { res.status(404).json({ - error: 'No quote available from this swapper', - } as ErrorResponse) - return + error: "No quote available from this swapper", + } as ErrorResponse); + return; } if (result.isErr()) { - const error = result.unwrapErr() + const error = result.unwrapErr(); res.status(400).json({ error: error.message, code: error.code, details: error.details, - } as ErrorResponse) - return + } as ErrorResponse); + return; } - const quotes = result.unwrap() + const quotes = result.unwrap(); if (quotes.length === 0) { - res.status(404).json({ error: 'No quote available' } as ErrorResponse) - return + res.status(404).json({ error: "No quote available" } as ErrorResponse); + return; } // Use the first/best quote - const quote = quotes[0] as TradeQuote - const firstStep = quote.steps[0] + const quote = quotes[0] as TradeQuote; + const firstStep = quote.steps[0]; // Calculate total buy amount (sum of all steps for multi-hop) - const lastStep = quote.steps[quote.steps.length - 1] + const lastStep = quote.steps[quote.steps.length - 1]; // Build response - const quoteId = uuidv4() - const now = Date.now() + const quoteId = uuidv4(); + const now = Date.now(); const response: QuoteResponse = { quoteId, @@ -260,21 +296,25 @@ export const getQuote = async (req: Request, res: Response): Promise => { rate: quote.rate, sellAsset, buyAsset, - sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, - buyAmountBeforeFeesCryptoBaseUnit: lastStep.buyAmountBeforeFeesCryptoBaseUnit, - buyAmountAfterFeesCryptoBaseUnit: lastStep.buyAmountAfterFeesCryptoBaseUnit, + sellAmountCryptoBaseUnit: + firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountBeforeFeesCryptoBaseUnit: + lastStep.buyAmountBeforeFeesCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit: + lastStep.buyAmountAfterFeesCryptoBaseUnit, affiliateBps: quote.affiliateBps, - slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, + slippageTolerancePercentageDecimal: + quote.slippageTolerancePercentageDecimal, networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, steps: quote.steps.map(transformQuoteStep), approval: buildApprovalInfo(firstStep, sellAssetId), expiresAt: now + 60_000, // 60 second expiry quote, // Include full quote for advanced consumers - } + }; - res.json(response) + res.json(response); } catch (error) { - console.error('Error in getQuote:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + console.error("Error in getQuote:", error); + res.status(500).json({ error: "Internal server error" } as ErrorResponse); } -} +}; diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 03b227f74f1..a958473c2b0 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -1,62 +1,72 @@ -import type { GetTradeRateInput } from '@shapeshiftoss/swapper' -import type { Request, Response } from 'express' -import { z } from 'zod' +import type { GetTradeRateInput } from "@shapeshiftoss/swapper"; +import type { Request, Response } from "express"; +import { z } from "zod"; -import { getAsset, getAssetsById } from '../assets' -import { DEFAULT_AFFILIATE_BPS } from '../config' -import { createServerSwapperDeps } from '../swapperDeps' -import type { ApiRate, ErrorResponse, RatesResponse } from '../types' +import { getAsset, getAssetsById } from "../assets"; +import { booleanFromString } from "../lib/zod"; +import { DEFAULT_AFFILIATE_BPS } from "../config"; +import { createServerSwapperDeps } from "../swapperDeps"; +import type { ApiRate, ErrorResponse, RatesResponse } from "../types"; // Request validation schema export const RatesRequestSchema = z.object({ - sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), - buyAssetId: z + sellAssetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), + buyAssetId: z.string().min(1).openapi({ + example: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }), + sellAmountCryptoBaseUnit: z .string() .min(1) - .openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }), - sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), - slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), - allowMultiHop: z.boolean().optional().default(true).openapi({ example: true }), -}) + .openapi({ example: "1000000000000000000" }), + slippageTolerancePercentageDecimal: z + .string() + .optional() + .openapi({ example: "0.01" }), + allowMultiHop: booleanFromString + .optional() + .default(true) + .openapi({ example: true }), +}); // Swapper names as strings to avoid import issues const ENABLED_SWAPPER_NAMES = [ - 'THORChain', - 'MAYAChain', - '0x', - 'CoW Swap', - 'Portals', - 'Chainflip', - 'Jupiter', - 'Relay', - 'ButterSwap', - 'Bebop', -] as const + "THORChain", + "MAYAChain", + "0x", + "CoW Swap", + "Portals", + "Chainflip", + "Jupiter", + "Relay", + "ButterSwap", + "Bebop", +] as const; // Rate timeout per swapper (10 seconds) -const RATE_TIMEOUT_MS = 10_000 +const RATE_TIMEOUT_MS = 10_000; // Lazy load swapper to avoid import issues at module load time -let swapperModule: Awaited> | null = null -const importSwapperModule = () => import('@shapeshiftoss/swapper') +let swapperModule: Awaited> | null = + null; +const importSwapperModule = () => import("@shapeshiftoss/swapper"); const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await importSwapperModule() + swapperModule = await importSwapperModule(); } - return swapperModule -} + return swapperModule; +}; export const getRates = async (req: Request, res: Response): Promise => { try { // Parse and validate request - const parseResult = RatesRequestSchema.safeParse(req.query) + const parseResult = RatesRequestSchema.safeParse(req.query); if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: 'Invalid request parameters', + error: "Invalid request parameters", details: parseResult.error.errors, - } - res.status(400).json(errorResponse) - return + }; + res.status(400).json(errorResponse); + return; } const { @@ -65,37 +75,41 @@ export const getRates = async (req: Request, res: Response): Promise => { sellAmountCryptoBaseUnit, slippageTolerancePercentageDecimal, allowMultiHop, - } = parseResult.data + } = parseResult.data; // Get assets - const sellAsset = getAsset(sellAssetId) - const buyAsset = getAsset(buyAssetId) + const sellAsset = getAsset(sellAssetId); + const buyAsset = getAsset(buyAssetId); if (!sellAsset) { - res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) - return + res + .status(400) + .json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse); + return; } if (!buyAsset) { - res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) - return + res + .status(400) + .json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse); + return; } // Lazy load swapper module - let swapperModuleResult + let swapperModuleResult; try { - swapperModuleResult = await getSwapperModule() + swapperModuleResult = await getSwapperModule(); } catch (error) { - console.error('Failed to load swapper module:', error) + console.error("Failed to load swapper module:", error); res.status(503).json({ - error: 'Swap service temporarily unavailable', - details: 'Failed to initialize swapper module', - } as ErrorResponse) - return + error: "Swap service temporarily unavailable", + details: "Failed to initialize swapper module", + } as ErrorResponse); + return; } - const { getTradeRates, swappers, SwapperName } = swapperModuleResult + const { getTradeRates, swappers, SwapperName } = swapperModuleResult; // Create swapper dependencies - const deps = createServerSwapperDeps(getAssetsById()) + const deps = createServerSwapperDeps(getAssetsById()); // Build rate input const rateInput = { @@ -108,103 +122,117 @@ export const getRates = async (req: Request, res: Response): Promise => { receiveAddress: undefined, sendAddress: undefined, accountNumber: undefined, - quoteOrRate: 'rate' as const, + quoteOrRate: "rate" as const, chainId: sellAsset.chainId, - } + }; // Map string names to SwapperName enum - const enabledSwappers = ENABLED_SWAPPER_NAMES.map(name => { - const swapperName = Object.values(SwapperName).find(v => v === name) - return swapperName - }).filter((name): name is (typeof SwapperName)[keyof typeof SwapperName] => name !== undefined) + const enabledSwappers = ENABLED_SWAPPER_NAMES.map((name) => { + const swapperName = Object.values(SwapperName).find((v) => v === name); + return swapperName; + }).filter( + (name): name is (typeof SwapperName)[keyof typeof SwapperName] => + name !== undefined, + ); // Fetch rates from all enabled swappers in parallel - const ratePromises = enabledSwappers.map(async (swapperName): Promise => { - try { - const swapper = swappers[swapperName] - if (!swapper) return null + const ratePromises = enabledSwappers.map( + async (swapperName): Promise => { + try { + const swapper = swappers[swapperName]; + if (!swapper) return null; + + const result = await getTradeRates( + rateInput as GetTradeRateInput, + swapperName, + deps, + RATE_TIMEOUT_MS, + ); + + if (!result) return null; + + if (result.isErr()) { + const error = result.unwrapErr(); + return { + swapperName, + rate: "0", + buyAmountCryptoBaseUnit: "0", + sellAmountCryptoBaseUnit, + steps: 0, + estimatedExecutionTimeMs: undefined, + priceImpactPercentageDecimal: undefined, + affiliateBps: DEFAULT_AFFILIATE_BPS, + networkFeeCryptoBaseUnit: undefined, + error: { + code: error.code || ("UnknownError" as any), + message: error.message, + }, + }; + } - const result = await getTradeRates( - rateInput as GetTradeRateInput, - swapperName, - deps, - RATE_TIMEOUT_MS, - ) + const rates = result.unwrap(); + if (rates.length === 0) return null; - if (!result) return null + // Return the first/best rate + const rate = rates[0]; + const firstStep = rate.steps[0]; - if (result.isErr()) { - const error = result.unwrapErr() return { swapperName, - rate: '0', - buyAmountCryptoBaseUnit: '0', - sellAmountCryptoBaseUnit, - steps: 0, - estimatedExecutionTimeMs: undefined, - priceImpactPercentageDecimal: undefined, - affiliateBps: DEFAULT_AFFILIATE_BPS, - networkFeeCryptoBaseUnit: undefined, - error: { - code: error.code || ('UnknownError' as any), - message: error.message, - }, - } + rate: rate.rate, + buyAmountCryptoBaseUnit: firstStep.buyAmountAfterFeesCryptoBaseUnit, + sellAmountCryptoBaseUnit: + firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + steps: rate.steps.length, + estimatedExecutionTimeMs: firstStep.estimatedExecutionTimeMs, + priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal, + affiliateBps: rate.affiliateBps, + networkFeeCryptoBaseUnit: + firstStep.feeData.networkFeeCryptoBaseUnit, + }; + } catch (error) { + console.error(`Error fetching rate from ${swapperName}:`, error); + return null; } + }, + ); - const rates = result.unwrap() - if (rates.length === 0) return null - - // Return the first/best rate - const rate = rates[0] - const firstStep = rate.steps[0] - - return { - swapperName, - rate: rate.rate, - buyAmountCryptoBaseUnit: firstStep.buyAmountAfterFeesCryptoBaseUnit, - sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, - steps: rate.steps.length, - estimatedExecutionTimeMs: firstStep.estimatedExecutionTimeMs, - priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal, - affiliateBps: rate.affiliateBps, - networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, - } - } catch (error) { - console.error(`Error fetching rate from ${swapperName}:`, error) - return null - } - }) - - const results = await Promise.allSettled(ratePromises) + const results = await Promise.allSettled(ratePromises); const rates: ApiRate[] = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') - .map(r => r.value) - .filter((r): r is ApiRate => r !== null) + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled", + ) + .map((r) => r.value) + .filter((r): r is ApiRate => r !== null); // Sort by best rate (highest buy amount) rates.sort((a, b) => { - if (a.error && !b.error) return 1 - if (!a.error && b.error) return -1 + if (a.error && !b.error) return 1; + if (!a.error && b.error) return -1; try { - const aBuyAmount = BigInt(a.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') - const bBuyAmount = BigInt(b.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') - return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0 + const aBuyAmount = BigInt( + a.buyAmountCryptoBaseUnit.split(".")[0] ?? "0", + ); + const bBuyAmount = BigInt( + b.buyAmountCryptoBaseUnit.split(".")[0] ?? "0", + ); + return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0; } catch { - return 0 + return 0; } - }) + }); - const now = Date.now() + const now = Date.now(); const response: RatesResponse = { rates, timestamp: now, expiresAt: now + 30_000, // 30 second expiry - } + }; - res.json(response) + res.json(response); } catch (error) { - console.error('Error in getRates:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + console.error("Error in getRates:", error); + res.status(500).json({ error: "Internal server error" } as ErrorResponse); } -} +}; From ffadcc3f53b3cdbf9c0d1c8d89426acbf860465e Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 19:50:09 +1100 Subject: [PATCH 39/46] fix(public-api): use env vars for EVM unchained URLs instead of hardcoded values Allow staging/multi-tenant environments to override EVM chain endpoints via environment variables while preserving existing defaults as fallbacks. Co-Authored-By: Claude --- packages/public-api/src/lib/zod.ts | 6 +- packages/public-api/src/routes/quote.ts | 229 ++++++++----------- packages/public-api/src/routes/rates.ts | 278 +++++++++++------------- packages/public-api/src/swapperDeps.ts | 34 ++- 4 files changed, 245 insertions(+), 302 deletions(-) diff --git a/packages/public-api/src/lib/zod.ts b/packages/public-api/src/lib/zod.ts index b08acd560b7..21543279075 100644 --- a/packages/public-api/src/lib/zod.ts +++ b/packages/public-api/src/lib/zod.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; +import { z } from 'zod' export const booleanFromString = z.preprocess( - (val) => (val === "true" ? true : val === "false" ? false : val), + val => (val === 'true' ? true : val === 'false' ? false : val), z.boolean(), -); +) diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index e4d6677c48e..daf8f1cc66c 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -1,75 +1,54 @@ -import { fromChainId } from "@shapeshiftoss/caip"; +import { fromChainId } from '@shapeshiftoss/caip' import type { GetTradeQuoteInputWithWallet, SwapperName, TradeQuote, TradeQuoteStep, -} from "@shapeshiftoss/swapper"; -import { isNativeEvmAsset } from "@shapeshiftoss/swapper"; -import type { Request, Response } from "express"; -import { v4 as uuidv4 } from "uuid"; -import { z } from "zod"; - -import { getAsset, getAssetsById } from "../assets"; -import { DEFAULT_AFFILIATE_BPS } from "../config"; -import { booleanFromString } from "../lib/zod"; -import { createServerSwapperDeps } from "../swapperDeps"; -import type { - ApiQuoteStep, - ApprovalInfo, - ErrorResponse, - QuoteResponse, -} from "../types"; +} from '@shapeshiftoss/swapper' +import { isNativeEvmAsset } from '@shapeshiftoss/swapper' +import type { Request, Response } from 'express' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' + +import { getAsset, getAssetsById } from '../assets' +import { DEFAULT_AFFILIATE_BPS } from '../config' +import { booleanFromString } from '../lib/zod' +import { createServerSwapperDeps } from '../swapperDeps' +import type { ApiQuoteStep, ApprovalInfo, ErrorResponse, QuoteResponse } from '../types' // Lazy load swapper to avoid import issues at module load time -let swapperModule: Awaited> | null = - null; -const importSwapperModule = () => import("@shapeshiftoss/swapper"); +let swapperModule: Awaited> | null = null +const importSwapperModule = () => import('@shapeshiftoss/swapper') const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await importSwapperModule(); + swapperModule = await importSwapperModule() } - return swapperModule; -}; + return swapperModule +} // Request validation schema - swapperName is string, validated later export const QuoteRequestSchema = z.object({ - sellAssetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), + sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), buyAssetId: z.string().min(1).openapi({ - example: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', }), - sellAmountCryptoBaseUnit: z - .string() - .min(1) - .openapi({ example: "1000000000000000000" }), + sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), receiveAddress: z .string() .min(1) - .openapi({ example: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28" }), + .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), sendAddress: z .string() .optional() - .openapi({ example: "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28" }), - swapperName: z.string().min(1).openapi({ example: "0x" }), - slippageTolerancePercentageDecimal: z - .string() - .optional() - .openapi({ example: "0.01" }), - allowMultiHop: booleanFromString - .optional() - .default(true) - .openapi({ example: true }), - accountNumber: z.coerce - .number() - .optional() - .default(0) - .openapi({ example: 0 }), -}); + .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + swapperName: z.string().min(1).openapi({ example: '0x' }), + slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + allowMultiHop: booleanFromString.optional().default(true).openapi({ example: true }), + accountNumber: z.coerce.number().optional().default(0).openapi({ example: 0 }), +}) // Helper to extract transaction data from quote step -const extractTransactionData = ( - step: TradeQuoteStep, -): ApiQuoteStep["transactionData"] => { +const extractTransactionData = (step: TradeQuoteStep): ApiQuoteStep['transactionData'] => { // Check for various swapper-specific transaction metadata if (step.zrxTransactionMetadata) { return { @@ -77,7 +56,7 @@ const extractTransactionData = ( data: step.zrxTransactionMetadata.data, value: step.zrxTransactionMetadata.value, gasLimit: step.zrxTransactionMetadata.gas, - }; + } } if (step.portalsTransactionMetadata) { @@ -86,7 +65,7 @@ const extractTransactionData = ( data: step.portalsTransactionMetadata.data, value: step.portalsTransactionMetadata.value, gasLimit: step.portalsTransactionMetadata.gasLimit, - }; + } } if (step.bebopTransactionMetadata) { @@ -95,7 +74,7 @@ const extractTransactionData = ( data: step.bebopTransactionMetadata.data, value: step.bebopTransactionMetadata.value, gasLimit: step.bebopTransactionMetadata.gas, - }; + } } if (step.butterSwapTransactionMetadata) { @@ -104,37 +83,34 @@ const extractTransactionData = ( data: step.butterSwapTransactionMetadata.data, value: step.butterSwapTransactionMetadata.value, gasLimit: step.butterSwapTransactionMetadata.gasLimit, - }; + } } // For THORChain/MAYAChain and other swappers, transaction is built differently // The consumer will need to use the quote data to build the transaction - return undefined; -}; + return undefined +} -const buildApprovalInfo = ( - step: TradeQuoteStep, - _sellAssetId: string, -): ApprovalInfo => { - const { chainNamespace } = fromChainId(step.sellAsset.chainId); +const buildApprovalInfo = (step: TradeQuoteStep, _sellAssetId: string): ApprovalInfo => { + const { chainNamespace } = fromChainId(step.sellAsset.chainId) - if (chainNamespace !== "eip155") { - return { isRequired: false, spender: "" }; + if (chainNamespace !== 'eip155') { + return { isRequired: false, spender: '' } } if (isNativeEvmAsset(step.sellAsset.assetId)) { - return { isRequired: false, spender: "" }; + return { isRequired: false, spender: '' } } if (step.allowanceContract) { return { isRequired: true, spender: step.allowanceContract, - }; + } } - return { isRequired: false, spender: "" }; -}; + return { isRequired: false, spender: '' } +} // Transform quote step to API format const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ @@ -146,19 +122,19 @@ const transformQuoteStep = (step: TradeQuoteStep): ApiQuoteStep => ({ estimatedExecutionTimeMs: step.estimatedExecutionTimeMs, source: step.source, transactionData: extractTransactionData(step), -}); +}) export const getQuote = async (req: Request, res: Response): Promise => { try { // Parse and validate request - const parseResult = QuoteRequestSchema.safeParse(req.body); + const parseResult = QuoteRequestSchema.safeParse(req.body) if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: "Invalid request parameters", + error: 'Invalid request parameters', details: parseResult.error.errors, - }; - res.status(400).json(errorResponse); - return; + } + res.status(400).json(errorResponse) + return } const { @@ -171,64 +147,53 @@ export const getQuote = async (req: Request, res: Response): Promise => { slippageTolerancePercentageDecimal, allowMultiHop, accountNumber, - } = parseResult.data; + } = parseResult.data // Lazy load swapper module - const { - getTradeQuotes, - swappers, - SwapperName, - getDefaultSlippageDecimalPercentageForSwapper, - } = await getSwapperModule(); + const { getTradeQuotes, swappers, SwapperName, getDefaultSlippageDecimalPercentageForSwapper } = + await getSwapperModule() // Validate swapper name - const validSwapperName = Object.values(SwapperName).find( - (v) => v === swapperName, - ) as SwapperName | undefined; + const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as + | SwapperName + | undefined if (!validSwapperName) { - res - .status(400) - .json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse); - return; + res.status(400).json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse) + return } // Validate swapper exists - const swapper = swappers[validSwapperName]; + const swapper = swappers[validSwapperName] if (!swapper) { res.status(400).json({ error: `Swapper not available: ${swapperName}`, - } as ErrorResponse); - return; + } as ErrorResponse) + return } // Get assets - const sellAsset = getAsset(sellAssetId); - const buyAsset = getAsset(buyAssetId); + const sellAsset = getAsset(sellAssetId) + const buyAsset = getAsset(buyAssetId) if (!sellAsset) { - res - .status(400) - .json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse); - return; + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) + return } if (!buyAsset) { - res - .status(400) - .json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse); - return; + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + return } // Create swapper dependencies - const deps = createServerSwapperDeps(getAssetsById()); + const deps = createServerSwapperDeps(getAssetsById()) // Get default slippage if not provided - let slippage = slippageTolerancePercentageDecimal; + let slippage = slippageTolerancePercentageDecimal if (!slippage) { try { - slippage = - getDefaultSlippageDecimalPercentageForSwapper(validSwapperName); + slippage = getDefaultSlippageDecimalPercentageForSwapper(validSwapperName) } catch { - slippage = "0.01"; // 1% default fallback + slippage = '0.01' // 1% default fallback } } @@ -243,52 +208,52 @@ export const getQuote = async (req: Request, res: Response): Promise => { receiveAddress, sendAddress, accountNumber, - quoteOrRate: "quote" as const, + quoteOrRate: 'quote' as const, chainId: sellAsset.chainId, // EVM-specific fields supportsEIP1559: true, - }; + } // Fetch quote const result = await getTradeQuotes( quoteInput as GetTradeQuoteInputWithWallet, validSwapperName, deps, - ); + ) if (!result) { res.status(404).json({ - error: "No quote available from this swapper", - } as ErrorResponse); - return; + error: 'No quote available from this swapper', + } as ErrorResponse) + return } if (result.isErr()) { - const error = result.unwrapErr(); + const error = result.unwrapErr() res.status(400).json({ error: error.message, code: error.code, details: error.details, - } as ErrorResponse); - return; + } as ErrorResponse) + return } - const quotes = result.unwrap(); + const quotes = result.unwrap() if (quotes.length === 0) { - res.status(404).json({ error: "No quote available" } as ErrorResponse); - return; + res.status(404).json({ error: 'No quote available' } as ErrorResponse) + return } // Use the first/best quote - const quote = quotes[0] as TradeQuote; - const firstStep = quote.steps[0]; + const quote = quotes[0] as TradeQuote + const firstStep = quote.steps[0] // Calculate total buy amount (sum of all steps for multi-hop) - const lastStep = quote.steps[quote.steps.length - 1]; + const lastStep = quote.steps[quote.steps.length - 1] // Build response - const quoteId = uuidv4(); - const now = Date.now(); + const quoteId = uuidv4() + const now = Date.now() const response: QuoteResponse = { quoteId, @@ -296,25 +261,21 @@ export const getQuote = async (req: Request, res: Response): Promise => { rate: quote.rate, sellAsset, buyAsset, - sellAmountCryptoBaseUnit: - firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, - buyAmountBeforeFeesCryptoBaseUnit: - lastStep.buyAmountBeforeFeesCryptoBaseUnit, - buyAmountAfterFeesCryptoBaseUnit: - lastStep.buyAmountAfterFeesCryptoBaseUnit, + sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountBeforeFeesCryptoBaseUnit: lastStep.buyAmountBeforeFeesCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit: lastStep.buyAmountAfterFeesCryptoBaseUnit, affiliateBps: quote.affiliateBps, - slippageTolerancePercentageDecimal: - quote.slippageTolerancePercentageDecimal, + slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, steps: quote.steps.map(transformQuoteStep), approval: buildApprovalInfo(firstStep, sellAssetId), expiresAt: now + 60_000, // 60 second expiry quote, // Include full quote for advanced consumers - }; + } - res.json(response); + res.json(response) } catch (error) { - console.error("Error in getQuote:", error); - res.status(500).json({ error: "Internal server error" } as ErrorResponse); + console.error('Error in getQuote:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) } -}; +} diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index a958473c2b0..86b6dc485a5 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -1,72 +1,62 @@ -import type { GetTradeRateInput } from "@shapeshiftoss/swapper"; -import type { Request, Response } from "express"; -import { z } from "zod"; +import type { GetTradeRateInput } from '@shapeshiftoss/swapper' +import type { Request, Response } from 'express' +import { z } from 'zod' -import { getAsset, getAssetsById } from "../assets"; -import { booleanFromString } from "../lib/zod"; -import { DEFAULT_AFFILIATE_BPS } from "../config"; -import { createServerSwapperDeps } from "../swapperDeps"; -import type { ApiRate, ErrorResponse, RatesResponse } from "../types"; +import { getAsset, getAssetsById } from '../assets' +import { DEFAULT_AFFILIATE_BPS } from '../config' +import { booleanFromString } from '../lib/zod' +import { createServerSwapperDeps } from '../swapperDeps' +import type { ApiRate, ErrorResponse, RatesResponse } from '../types' // Request validation schema export const RatesRequestSchema = z.object({ - sellAssetId: z.string().min(1).openapi({ example: "eip155:1/slip44:60" }), + sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), buyAssetId: z.string().min(1).openapi({ - example: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', }), - sellAmountCryptoBaseUnit: z - .string() - .min(1) - .openapi({ example: "1000000000000000000" }), - slippageTolerancePercentageDecimal: z - .string() - .optional() - .openapi({ example: "0.01" }), - allowMultiHop: booleanFromString - .optional() - .default(true) - .openapi({ example: true }), -}); + sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), + slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + allowMultiHop: booleanFromString.optional().default(true).openapi({ example: true }), +}) // Swapper names as strings to avoid import issues const ENABLED_SWAPPER_NAMES = [ - "THORChain", - "MAYAChain", - "0x", - "CoW Swap", - "Portals", - "Chainflip", - "Jupiter", - "Relay", - "ButterSwap", - "Bebop", -] as const; + 'THORChain', + 'MAYAChain', + '0x', + 'CoW Swap', + 'Portals', + 'Chainflip', + 'Jupiter', + 'Relay', + 'ButterSwap', + 'Bebop', +] as const // Rate timeout per swapper (10 seconds) -const RATE_TIMEOUT_MS = 10_000; +const RATE_TIMEOUT_MS = 10_000 // Lazy load swapper to avoid import issues at module load time -let swapperModule: Awaited> | null = - null; -const importSwapperModule = () => import("@shapeshiftoss/swapper"); +let swapperModule: Awaited> | null = null +const importSwapperModule = () => import('@shapeshiftoss/swapper') const getSwapperModule = async () => { if (!swapperModule) { - swapperModule = await importSwapperModule(); + swapperModule = await importSwapperModule() } - return swapperModule; -}; + return swapperModule +} export const getRates = async (req: Request, res: Response): Promise => { try { // Parse and validate request - const parseResult = RatesRequestSchema.safeParse(req.query); + const parseResult = RatesRequestSchema.safeParse(req.query) if (!parseResult.success) { const errorResponse: ErrorResponse = { - error: "Invalid request parameters", + error: 'Invalid request parameters', details: parseResult.error.errors, - }; - res.status(400).json(errorResponse); - return; + } + res.status(400).json(errorResponse) + return } const { @@ -75,41 +65,37 @@ export const getRates = async (req: Request, res: Response): Promise => { sellAmountCryptoBaseUnit, slippageTolerancePercentageDecimal, allowMultiHop, - } = parseResult.data; + } = parseResult.data // Get assets - const sellAsset = getAsset(sellAssetId); - const buyAsset = getAsset(buyAssetId); + const sellAsset = getAsset(sellAssetId) + const buyAsset = getAsset(buyAssetId) if (!sellAsset) { - res - .status(400) - .json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse); - return; + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) + return } if (!buyAsset) { - res - .status(400) - .json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse); - return; + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + return } // Lazy load swapper module - let swapperModuleResult; + let swapperModuleResult try { - swapperModuleResult = await getSwapperModule(); + swapperModuleResult = await getSwapperModule() } catch (error) { - console.error("Failed to load swapper module:", error); + console.error('Failed to load swapper module:', error) res.status(503).json({ - error: "Swap service temporarily unavailable", - details: "Failed to initialize swapper module", - } as ErrorResponse); - return; + error: 'Swap service temporarily unavailable', + details: 'Failed to initialize swapper module', + } as ErrorResponse) + return } - const { getTradeRates, swappers, SwapperName } = swapperModuleResult; + const { getTradeRates, swappers, SwapperName } = swapperModuleResult // Create swapper dependencies - const deps = createServerSwapperDeps(getAssetsById()); + const deps = createServerSwapperDeps(getAssetsById()) // Build rate input const rateInput = { @@ -122,117 +108,103 @@ export const getRates = async (req: Request, res: Response): Promise => { receiveAddress: undefined, sendAddress: undefined, accountNumber: undefined, - quoteOrRate: "rate" as const, + quoteOrRate: 'rate' as const, chainId: sellAsset.chainId, - }; + } // Map string names to SwapperName enum - const enabledSwappers = ENABLED_SWAPPER_NAMES.map((name) => { - const swapperName = Object.values(SwapperName).find((v) => v === name); - return swapperName; - }).filter( - (name): name is (typeof SwapperName)[keyof typeof SwapperName] => - name !== undefined, - ); + const enabledSwappers = ENABLED_SWAPPER_NAMES.map(name => { + const swapperName = Object.values(SwapperName).find(v => v === name) + return swapperName + }).filter((name): name is (typeof SwapperName)[keyof typeof SwapperName] => name !== undefined) // Fetch rates from all enabled swappers in parallel - const ratePromises = enabledSwappers.map( - async (swapperName): Promise => { - try { - const swapper = swappers[swapperName]; - if (!swapper) return null; - - const result = await getTradeRates( - rateInput as GetTradeRateInput, - swapperName, - deps, - RATE_TIMEOUT_MS, - ); - - if (!result) return null; - - if (result.isErr()) { - const error = result.unwrapErr(); - return { - swapperName, - rate: "0", - buyAmountCryptoBaseUnit: "0", - sellAmountCryptoBaseUnit, - steps: 0, - estimatedExecutionTimeMs: undefined, - priceImpactPercentageDecimal: undefined, - affiliateBps: DEFAULT_AFFILIATE_BPS, - networkFeeCryptoBaseUnit: undefined, - error: { - code: error.code || ("UnknownError" as any), - message: error.message, - }, - }; - } + const ratePromises = enabledSwappers.map(async (swapperName): Promise => { + try { + const swapper = swappers[swapperName] + if (!swapper) return null - const rates = result.unwrap(); - if (rates.length === 0) return null; + const result = await getTradeRates( + rateInput as GetTradeRateInput, + swapperName, + deps, + RATE_TIMEOUT_MS, + ) - // Return the first/best rate - const rate = rates[0]; - const firstStep = rate.steps[0]; + if (!result) return null + if (result.isErr()) { + const error = result.unwrapErr() return { swapperName, - rate: rate.rate, - buyAmountCryptoBaseUnit: firstStep.buyAmountAfterFeesCryptoBaseUnit, - sellAmountCryptoBaseUnit: - firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, - steps: rate.steps.length, - estimatedExecutionTimeMs: firstStep.estimatedExecutionTimeMs, - priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal, - affiliateBps: rate.affiliateBps, - networkFeeCryptoBaseUnit: - firstStep.feeData.networkFeeCryptoBaseUnit, - }; - } catch (error) { - console.error(`Error fetching rate from ${swapperName}:`, error); - return null; + rate: '0', + buyAmountCryptoBaseUnit: '0', + sellAmountCryptoBaseUnit, + steps: 0, + estimatedExecutionTimeMs: undefined, + priceImpactPercentageDecimal: undefined, + affiliateBps: DEFAULT_AFFILIATE_BPS, + networkFeeCryptoBaseUnit: undefined, + error: { + code: error.code || ('UnknownError' as any), + message: error.message, + }, + } } - }, - ); - const results = await Promise.allSettled(ratePromises); + const rates = result.unwrap() + if (rates.length === 0) return null + + // Return the first/best rate + const rate = rates[0] + const firstStep = rate.steps[0] + + return { + swapperName, + rate: rate.rate, + buyAmountCryptoBaseUnit: firstStep.buyAmountAfterFeesCryptoBaseUnit, + sellAmountCryptoBaseUnit: firstStep.sellAmountIncludingProtocolFeesCryptoBaseUnit, + steps: rate.steps.length, + estimatedExecutionTimeMs: firstStep.estimatedExecutionTimeMs, + priceImpactPercentageDecimal: rate.priceImpactPercentageDecimal, + affiliateBps: rate.affiliateBps, + networkFeeCryptoBaseUnit: firstStep.feeData.networkFeeCryptoBaseUnit, + } + } catch (error) { + console.error(`Error fetching rate from ${swapperName}:`, error) + return null + } + }) + + const results = await Promise.allSettled(ratePromises) const rates: ApiRate[] = results - .filter( - (r): r is PromiseFulfilledResult => - r.status === "fulfilled", - ) - .map((r) => r.value) - .filter((r): r is ApiRate => r !== null); + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value) + .filter((r): r is ApiRate => r !== null) // Sort by best rate (highest buy amount) rates.sort((a, b) => { - if (a.error && !b.error) return 1; - if (!a.error && b.error) return -1; + if (a.error && !b.error) return 1 + if (!a.error && b.error) return -1 try { - const aBuyAmount = BigInt( - a.buyAmountCryptoBaseUnit.split(".")[0] ?? "0", - ); - const bBuyAmount = BigInt( - b.buyAmountCryptoBaseUnit.split(".")[0] ?? "0", - ); - return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0; + const aBuyAmount = BigInt(a.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') + const bBuyAmount = BigInt(b.buyAmountCryptoBaseUnit.split('.')[0] ?? '0') + return bBuyAmount > aBuyAmount ? 1 : bBuyAmount < aBuyAmount ? -1 : 0 } catch { - return 0; + return 0 } - }); + }) - const now = Date.now(); + const now = Date.now() const response: RatesResponse = { rates, timestamp: now, expiresAt: now + 30_000, // 30 second expiry - }; + } - res.json(response); + res.json(response) } catch (error) { - console.error("Error in getRates:", error); - res.status(500).json({ error: "Internal server error" } as ErrorResponse); + console.error('Error in getRates:', error) + res.status(500).json({ error: 'Internal server error' } as ErrorResponse) } -}; +} diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index e6f235b8498..707c11681c2 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -29,16 +29,24 @@ type GasFeeDataEstimate = { slow: GasFeeData } -const EVM_UNCHAINED_URLS: Record = { - [KnownChainIds.EthereumMainnet]: 'https://api.ethereum.shapeshift.com', - [KnownChainIds.ArbitrumMainnet]: 'https://api.arbitrum.shapeshift.com', - [KnownChainIds.OptimismMainnet]: 'https://api.optimism.shapeshift.com', - [KnownChainIds.PolygonMainnet]: 'https://api.polygon.shapeshift.com', - [KnownChainIds.GnosisMainnet]: 'https://api.gnosis.shapeshift.com', - [KnownChainIds.AvalancheMainnet]: 'https://api.avalanche.shapeshift.com', - [KnownChainIds.BnbSmartChainMainnet]: 'https://api.bnbsmartchain.shapeshift.com', - [KnownChainIds.BaseMainnet]: 'https://api.base.shapeshift.com', - [KnownChainIds.ArbitrumNovaMainnet]: 'https://api.arbitrum-nova.shapeshift.com', +const getEvmUnchainedUrls = (): Record => { + const config = getServerConfig() + return { + [KnownChainIds.EthereumMainnet]: config.VITE_UNCHAINED_ETHEREUM_HTTP_URL, + [KnownChainIds.ArbitrumMainnet]: + process.env.UNCHAINED_ARBITRUM_HTTP_URL || 'https://api.arbitrum.shapeshift.com', + [KnownChainIds.OptimismMainnet]: + process.env.UNCHAINED_OPTIMISM_HTTP_URL || 'https://api.optimism.shapeshift.com', + [KnownChainIds.PolygonMainnet]: + process.env.UNCHAINED_POLYGON_HTTP_URL || 'https://api.polygon.shapeshift.com', + [KnownChainIds.GnosisMainnet]: + process.env.UNCHAINED_GNOSIS_HTTP_URL || 'https://api.gnosis.shapeshift.com', + [KnownChainIds.AvalancheMainnet]: config.VITE_UNCHAINED_AVALANCHE_HTTP_URL, + [KnownChainIds.BnbSmartChainMainnet]: config.VITE_UNCHAINED_BNBSMARTCHAIN_HTTP_URL, + [KnownChainIds.BaseMainnet]: config.VITE_UNCHAINED_BASE_HTTP_URL, + [KnownChainIds.ArbitrumNovaMainnet]: + process.env.UNCHAINED_ARBITRUM_NOVA_HTTP_URL || 'https://api.arbitrum-nova.shapeshift.com', + } } const fetchGasFees = async (unchainedUrl: string): Promise => { @@ -67,7 +75,8 @@ const fetchGasFees = async (unchainedUrl: string): Promise = } const createMinimalEvmAdapter = (chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + const evmUnchainedUrls = getEvmUnchainedUrls() + const unchainedUrl = evmUnchainedUrls[chainId] if (!unchainedUrl) { throw new Error(`No Unchained URL configured for chain ${chainId}`) } @@ -122,7 +131,8 @@ export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperD ) => ChainAdapter, assertGetEvmChainAdapter: ((chainId: ChainId) => { - const unchainedUrl = EVM_UNCHAINED_URLS[chainId] + const evmUnchainedUrls = getEvmUnchainedUrls() + const unchainedUrl = evmUnchainedUrls[chainId] if (unchainedUrl) { return createMinimalEvmAdapter(chainId) } From 688304a7a68c58d7c76e1ad098eca573c8da7723 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 20:10:27 +1100 Subject: [PATCH 40/46] docs(public-api): add CLAUDE.md with LLM instructions --- packages/public-api/CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/public-api/CLAUDE.md diff --git a/packages/public-api/CLAUDE.md b/packages/public-api/CLAUDE.md new file mode 100644 index 00000000000..36d9178eb89 --- /dev/null +++ b/packages/public-api/CLAUDE.md @@ -0,0 +1,15 @@ +# Public API - LLM Instructions + +## Running the Server Locally (Recommended) + +Use bundled mode to avoid ESM/tsx module resolution issues: + +```bash +yarn build:bundle && yarn start:prod +``` + +This bundles everything with esbuild into `dist/server.cjs` and runs it with plain Node.js. + +## Code Style + +This codebase does **not** use semicolons. Follow the existing style in each file. From 4d0f528bcd97cacad2cf7caa9cad3a2fe7e1b96e Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Mon, 19 Jan 2026 20:21:39 +1100 Subject: [PATCH 41/46] docs: update quote endpoint example to swap ETH to BTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the sample request in the OpenAPI docs from ETHβ†’USDC to ETHβ†’BTC using the Relay swapper, with appropriate Bitcoin receive address. Co-Authored-By: Claude --- packages/public-api/src/docs/openapi.ts | 6 +++--- packages/public-api/src/routes/quote.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/public-api/src/docs/openapi.ts b/packages/public-api/src/docs/openapi.ts index 83a095b456e..5f3fc5ffa1c 100644 --- a/packages/public-api/src/docs/openapi.ts +++ b/packages/public-api/src/docs/openapi.ts @@ -255,10 +255,10 @@ Once the user selects a rate, request an executable quote with transaction data: POST /v1/swap/quote { "sellAssetId": "eip155:1/slip44:60", - "buyAssetId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "buyAssetId": "bip122:000000000019d6689c085ae165831e93/slip44:0", "sellAmountCryptoBaseUnit": "1000000000000000000", - "swapperName": "0x", - "receiveAddress": "0x...", + "swapperName": "Relay", + "receiveAddress": "bc1q...", "sendAddress": "0x..." } \`\`\` diff --git a/packages/public-api/src/routes/quote.ts b/packages/public-api/src/routes/quote.ts index daf8f1cc66c..6b3c35cb587 100644 --- a/packages/public-api/src/routes/quote.ts +++ b/packages/public-api/src/routes/quote.ts @@ -30,18 +30,18 @@ const getSwapperModule = async () => { export const QuoteRequestSchema = z.object({ sellAssetId: z.string().min(1).openapi({ example: 'eip155:1/slip44:60' }), buyAssetId: z.string().min(1).openapi({ - example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + example: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }), sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), receiveAddress: z .string() .min(1) - .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), + .openapi({ example: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }), sendAddress: z .string() .optional() - .openapi({ example: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28' }), - swapperName: z.string().min(1).openapi({ example: '0x' }), + .openapi({ example: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }), + swapperName: z.string().min(1).openapi({ example: 'Relay' }), slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), allowMultiHop: booleanFromString.optional().default(true).openapi({ example: true }), accountNumber: z.coerce.number().optional().default(0).openapi({ example: 0 }), From 3e1527a8969b014fd945c8ad0428530e1a32837c Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 20 Jan 2026 10:38:47 +1100 Subject: [PATCH 42/46] fix(public-api): implement UTXO chain adapter for BTC sell support THORChain BTC sell trades were failing because assertGetUtxoChainAdapter was a stub that threw an error. This implements a minimal UTXO adapter that provides the required interface for rate requests. Co-Authored-By: Claude --- packages/public-api/src/swapperDeps.ts | 107 ++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index 707c11681c2..91f8f7b0d69 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -29,6 +29,12 @@ type GasFeeDataEstimate = { slow: GasFeeData } +type UtxoNetworkFees = { + fast: { satsPerKiloByte: number } + average: { satsPerKiloByte: number } + slow: { satsPerKiloByte: number } +} + const getEvmUnchainedUrls = (): Record => { const config = getServerConfig() return { @@ -49,6 +55,16 @@ const getEvmUnchainedUrls = (): Record => { } } +const getUtxoUnchainedUrls = (): Record => { + const config = getServerConfig() + return { + [KnownChainIds.BitcoinMainnet]: config.VITE_UNCHAINED_BITCOIN_HTTP_URL, + [KnownChainIds.DogecoinMainnet]: config.VITE_UNCHAINED_DOGECOIN_HTTP_URL, + [KnownChainIds.LitecoinMainnet]: config.VITE_UNCHAINED_LITECOIN_HTTP_URL, + [KnownChainIds.BitcoinCashMainnet]: config.VITE_UNCHAINED_BITCOINCASH_HTTP_URL, + } +} + const fetchGasFees = async (unchainedUrl: string): Promise => { const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`) if (!response.ok) { @@ -112,6 +128,85 @@ const createMinimalEvmAdapter = (chainId: ChainId) => { } } +const createMinimalUtxoAdapter = (chainId: ChainId) => { + const utxoUnchainedUrls = getUtxoUnchainedUrls() + const unchainedUrl = utxoUnchainedUrls[chainId] + if (!unchainedUrl) { + throw new Error(`No Unchained URL configured for UTXO chain ${chainId}`) + } + + const getFeeAssetId = () => { + switch (chainId) { + case KnownChainIds.BitcoinMainnet: + return 'bip122:000000000019d6689c085ae165831e93/slip44:0' + case KnownChainIds.DogecoinMainnet: + return 'bip122:00000000001a91e3dace36e2be3bf030/slip44:3' + case KnownChainIds.LitecoinMainnet: + return 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2' + case KnownChainIds.BitcoinCashMainnet: + return 'bip122:000000000000000000651ef99cb9fcbe/slip44:145' + default: + throw new Error(`Unknown UTXO chain ${chainId}`) + } + } + + const getDisplayName = () => { + switch (chainId) { + case KnownChainIds.BitcoinMainnet: + return 'Bitcoin' + case KnownChainIds.DogecoinMainnet: + return 'Dogecoin' + case KnownChainIds.LitecoinMainnet: + return 'Litecoin' + case KnownChainIds.BitcoinCashMainnet: + return 'Bitcoin Cash' + default: + return chainId + } + } + + return { + getChainId: () => chainId, + getFeeAssetId, + getDisplayName, + getFeeData: async (_input: { + to: string + value: string + chainSpecific: { pubkey: string; opReturnData?: string } + }) => { + const response = await fetch(`${unchainedUrl}/api/v1/fees`) + if (!response.ok) { + throw new Error(`Failed to fetch UTXO fees: ${response.statusText}`) + } + const networkFees = (await response.json()) as UtxoNetworkFees + + const fastPerByte = String(Math.round(networkFees.fast.satsPerKiloByte / 1000)) + const averagePerByte = String(Math.round(networkFees.average.satsPerKiloByte / 1000)) + const slowPerByte = String(Math.round(networkFees.slow.satsPerKiloByte / 1000)) + + const ESTIMATED_TX_SIZE = 250 + const fastFee = String(parseInt(fastPerByte) * ESTIMATED_TX_SIZE) + const averageFee = String(parseInt(averagePerByte) * ESTIMATED_TX_SIZE) + const slowFee = String(parseInt(slowPerByte) * ESTIMATED_TX_SIZE) + + return { + fast: { + txFee: fastFee, + chainSpecific: { satoshiPerByte: fastPerByte }, + }, + average: { + txFee: averageFee, + chainSpecific: { satoshiPerByte: averagePerByte }, + }, + slow: { + txFee: slowFee, + chainSpecific: { satoshiPerByte: slowPerByte }, + }, + } + }, + } +} + const createStubAdapter = (type: string) => { return () => { throw new Error( @@ -139,9 +234,15 @@ export const createServerSwapperDeps = (assetsById: AssetsByIdPartial): SwapperD throw new Error(`Chain adapter EVM for ${chainId} not implemented in public API.`) }) as unknown as (chainId: ChainId) => EvmChainAdapter, - assertGetUtxoChainAdapter: createStubAdapter('UTXO') as unknown as ( - chainId: ChainId, - ) => UtxoChainAdapter, + assertGetUtxoChainAdapter: ((chainId: ChainId) => { + const utxoUnchainedUrls = getUtxoUnchainedUrls() + const unchainedUrl = utxoUnchainedUrls[chainId] + if (unchainedUrl) { + return createMinimalUtxoAdapter(chainId) + } + throw new Error(`Chain adapter UTXO for ${chainId} not implemented in public API.`) + }) as unknown as (chainId: ChainId) => UtxoChainAdapter, + assertGetCosmosSdkChainAdapter: createStubAdapter('CosmosSdk') as unknown as ( chainId: ChainId, ) => CosmosSdkChainAdapter, From 27f5e1d20395475c921be0452ed44602903919a0 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 20 Jan 2026 10:59:46 +1100 Subject: [PATCH 43/46] fix(swapper): handle unsupported chains gracefully in Portals and Chainflip - Update default THORChain Midgard URL to ninerealms.com for reliability - Fix Portals swapper to validate chain support before calling EVM adapter - Fix Chainflip swapper to handle undefined error properties with optional chaining Co-Authored-By: Claude --- packages/public-api/src/config.ts | 2 +- .../ChainflipSwapper/utils/getQuoteOrRate.ts | 8 ++++---- .../getPortalsTradeRate/getPortalsTradeRate.tsx | 14 +++++++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index 44610242c08..421cf3ef967 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -15,7 +15,7 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL === 'true', VITE_THORCHAIN_MIDGARD_URL: - process.env.THORCHAIN_MIDGARD_URL || 'https://midgard.thorchain.info/v2', + process.env.THORCHAIN_MIDGARD_URL || 'https://midgard.ninerealms.com/v2', VITE_MAYACHAIN_MIDGARD_URL: process.env.MAYACHAIN_MIDGARD_URL || 'https://midgard.mayachain.info/v2', VITE_UNCHAINED_BITCOIN_HTTP_URL: diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/getQuoteOrRate.ts index 94e40bc4118..1d1f015d68c 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/getQuoteOrRate.ts @@ -127,16 +127,16 @@ export const getQuoteOrRate = async ( if (maybeQuoteResponse.isErr()) { const error = maybeQuoteResponse.unwrapErr() - const cause = error.cause as AxiosError + const cause = error.cause as AxiosError | undefined if ( - cause.message.includes('code 400') && - cause.response?.data.detail.includes('Amount outside asset bounds') + cause?.message?.includes('code 400') && + cause?.response?.data?.detail?.includes('Amount outside asset bounds') ) { return Err( createTradeAmountTooSmallErr({ assetId: sellAsset.assetId, - minAmountCryptoBaseUnit: cause.response?.data.errors.minimalAmountNative[0], + minAmountCryptoBaseUnit: cause.response?.data?.errors?.minimalAmountNative?.[0], }), ) } diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.tsx b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.tsx index 34ed9d53d21..7982e020512 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.tsx +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.tsx @@ -41,7 +41,6 @@ export async function getPortalsTradeRate( receiveAddress, supportsEIP1559, } = input - const adapter = assertGetEvmChainAdapter(chainId) const sellAssetChainId = sellAsset.chainId const buyAssetChainId = buyAsset.chainId @@ -66,11 +65,20 @@ export async function getPortalsTradeRate( ) } + if (!isSupportedChainId(chainId)) { + return Err( + makeSwapErrorRight({ + message: `unsupported chainId`, + code: TradeQuoteError.UnsupportedChain, + details: { chainId }, + }), + ) + } + + const adapter = assertGetEvmChainAdapter(chainId) const isCrossChain = sellAssetChainId !== buyAssetChainId try { - if (!isSupportedChainId(chainId)) throw new Error(`Unsupported chainId ${sellAsset.chainId}`) - const sellPortalsNetwork = chainIdToPortalsNetwork[sellAssetChainId as KnownChainIds] const buyPortalsNetwork = chainIdToPortalsNetwork[buyAssetChainId as KnownChainIds] From 029d3bd240a0706bdf226e5515a971b4b1650a17 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 20 Jan 2026 11:04:56 +1100 Subject: [PATCH 44/46] fix(public-api): add strict regex validation to rates request schema Validate sellAmountCryptoBaseUnit with /^\d+$/ to only accept positive integers and slippageTolerancePercentageDecimal with /^(?:\d+)(?:\.\d+)?$/ to accept non-negative decimals, rejecting malformed numeric input at the API boundary. Co-Authored-By: Claude --- packages/public-api/src/routes/rates.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 86b6dc485a5..7e8b8cbe276 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -14,8 +14,18 @@ export const RatesRequestSchema = z.object({ buyAssetId: z.string().min(1).openapi({ example: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', }), - sellAmountCryptoBaseUnit: z.string().min(1).openapi({ example: '1000000000000000000' }), - slippageTolerancePercentageDecimal: z.string().optional().openapi({ example: '0.01' }), + sellAmountCryptoBaseUnit: z + .string() + .regex(/^\d+$/, 'sellAmountCryptoBaseUnit must be a positive integer') + .openapi({ example: '1000000000000000000' }), + slippageTolerancePercentageDecimal: z + .string() + .regex( + /^(?:\d+)(?:\.\d+)?$/, + 'slippageTolerancePercentageDecimal must be a non-negative decimal number', + ) + .optional() + .openapi({ example: '0.01' }), allowMultiHop: booleanFromString.optional().default(true).openapi({ example: true }), }) From 3545771fb087d335f5475330717ee65ad418e5a4 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 20 Jan 2026 11:10:16 +1100 Subject: [PATCH 45/46] fix(public-api): replace unsafe any-cast with typed TradeQuoteError fallback Use nullish coalescing with TradeQuoteError.UnknownError instead of casting 'UnknownError' as any for proper type safety. Co-Authored-By: Claude --- packages/public-api/src/routes/rates.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/public-api/src/routes/rates.ts b/packages/public-api/src/routes/rates.ts index 7e8b8cbe276..d5a4f205c09 100644 --- a/packages/public-api/src/routes/rates.ts +++ b/packages/public-api/src/routes/rates.ts @@ -1,4 +1,5 @@ import type { GetTradeRateInput } from '@shapeshiftoss/swapper' +import { TradeQuoteError } from '@shapeshiftoss/swapper' import type { Request, Response } from 'express' import { z } from 'zod' @@ -156,7 +157,7 @@ export const getRates = async (req: Request, res: Response): Promise => { affiliateBps: DEFAULT_AFFILIATE_BPS, networkFeeCryptoBaseUnit: undefined, error: { - code: error.code || ('UnknownError' as any), + code: error.code ?? TradeQuoteError.UnknownError, message: error.message, }, } From 318e59cebfd5545bbdedef05e0175afde5c40813 Mon Sep 17 00:00:00 2001 From: Apotheosis <0xapotheosis@gmail.com> Date: Tue, 20 Jan 2026 11:13:51 +1100 Subject: [PATCH 46/46] fix(public-api): add timeout to fetchGasFees using AbortController Prevents hanging requests when unchained gas fees endpoint is slow/unresponsive. Uses 10s timeout consistent with RATE_TIMEOUT_MS in rates route. Co-Authored-By: Claude --- packages/public-api/src/swapperDeps.ts | 53 ++++++++++++++++---------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index 91f8f7b0d69..3545efe0f6a 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -65,28 +65,39 @@ const getUtxoUnchainedUrls = (): Record => { } } +const GAS_FEES_TIMEOUT_MS = 10_000 + const fetchGasFees = async (unchainedUrl: string): Promise => { - const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`) - if (!response.ok) { - throw new Error(`Failed to fetch gas fees: ${response.statusText}`) - } - const data = (await response.json()) as GasFeeDataEstimate - return { - fast: { - gasPrice: data.fast.gasPrice, - maxFeePerGas: data.fast.maxFeePerGas, - maxPriorityFeePerGas: data.fast.maxPriorityFeePerGas, - }, - average: { - gasPrice: data.average.gasPrice, - maxFeePerGas: data.average.maxFeePerGas, - maxPriorityFeePerGas: data.average.maxPriorityFeePerGas, - }, - slow: { - gasPrice: data.slow.gasPrice, - maxFeePerGas: data.slow.maxFeePerGas, - maxPriorityFeePerGas: data.slow.maxPriorityFeePerGas, - }, + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), GAS_FEES_TIMEOUT_MS) + + try { + const response = await fetch(`${unchainedUrl}/api/v1/gas/fees`, { + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`Failed to fetch gas fees: ${response.statusText}`) + } + const data = (await response.json()) as GasFeeDataEstimate + return { + fast: { + gasPrice: data.fast.gasPrice, + maxFeePerGas: data.fast.maxFeePerGas, + maxPriorityFeePerGas: data.fast.maxPriorityFeePerGas, + }, + average: { + gasPrice: data.average.gasPrice, + maxFeePerGas: data.average.maxFeePerGas, + maxPriorityFeePerGas: data.average.maxPriorityFeePerGas, + }, + slow: { + gasPrice: data.slow.gasPrice, + maxFeePerGas: data.slow.maxFeePerGas, + maxPriorityFeePerGas: data.slow.maxPriorityFeePerGas, + }, + } + } finally { + clearTimeout(timeout) } }