diff --git a/packages/affiliate-dashboard/.env.example b/packages/affiliate-dashboard/.env.example new file mode 100644 index 00000000000..bed9f3425ac --- /dev/null +++ b/packages/affiliate-dashboard/.env.example @@ -0,0 +1,2 @@ +VITE_WALLETCONNECT_PROJECT_ID= +VITE_API_URL=https://dev-api.shapeshift.com diff --git a/packages/affiliate-dashboard/Dockerfile b/packages/affiliate-dashboard/Dockerfile deleted file mode 100644 index 16f4a1cb627..00000000000 --- a/packages/affiliate-dashboard/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM node:22-alpine AS builder -WORKDIR /app - -COPY packages/affiliate-dashboard/package.json ./ -RUN npm install - -COPY packages/affiliate-dashboard/index.html packages/affiliate-dashboard/tsconfig.json packages/affiliate-dashboard/tsconfig.node.json packages/affiliate-dashboard/vite.config.ts ./ -COPY packages/affiliate-dashboard/src/ ./src/ - -RUN npx vite build - -FROM nginx:stable-alpine AS runner -COPY --from=builder /app/dist /usr/share/nginx/html -RUN printf 'server {\n\ - listen 8080;\n\ - server_name _;\n\ - root /usr/share/nginx/html;\n\ - index index.html;\n\ - location /v1/ {\n\ - proxy_pass ${API_URL}/v1/;\n\ - proxy_http_version 1.1;\n\ - proxy_set_header Host api.shapeshift.com;\n\ - proxy_set_header X-Real-IP $remote_addr;\n\ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\ - proxy_set_header X-Forwarded-Proto $scheme;\n\ - }\n\ - location / {\n\ - try_files $uri $uri/ /index.html;\n\ - }\n\ -}\n' > /tmp/default.conf.template -# Default to production API if API_URL not set -ENV API_URL=https://api.shapeshift.com -EXPOSE 8080 -CMD ["/bin/sh", "-c", "envsubst '${API_URL}' < /tmp/default.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"] diff --git a/packages/affiliate-dashboard/package.json b/packages/affiliate-dashboard/package.json index 928c11ef596..ce89d66a73d 100644 --- a/packages/affiliate-dashboard/package.json +++ b/packages/affiliate-dashboard/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "type": "module", "scripts": { + "clean": "rm -rf dist node_modules", "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" diff --git a/packages/affiliate-dashboard/railway.json b/packages/affiliate-dashboard/railway.json index 390a02ae922..d5a3b8336d6 100644 --- a/packages/affiliate-dashboard/railway.json +++ b/packages/affiliate-dashboard/railway.json @@ -1,8 +1,7 @@ { "$schema": "https://railway.com/railway.schema.json", "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "packages/affiliate-dashboard/Dockerfile", + "builder": "RAILPACK", "watchPatterns": [ "packages/affiliate-dashboard/**" ] diff --git a/packages/affiliate-dashboard/src/App.tsx b/packages/affiliate-dashboard/src/App.tsx index 0a7fafd4cb7..c10d54913ab 100644 --- a/packages/affiliate-dashboard/src/App.tsx +++ b/packages/affiliate-dashboard/src/App.tsx @@ -133,7 +133,7 @@ const ShapeShiftLogo = (): React.JSX.Element => ( ) -const API_BASE = '/v1/affiliate' +const AFFILIATE_URL = `${import.meta.env.VITE_API_URL}/v1/affiliate` type Tab = 'overview' | 'swaps' | 'settings' @@ -224,7 +224,7 @@ export const App = (): React.JSX.Element => { setActionLoading(true) clearActionMessage() try { - const res = await fetch(API_BASE, { + const res = await fetch(AFFILIATE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify({ @@ -233,8 +233,8 @@ export const App = (): React.JSX.Element => { }), }) if (!res.ok) { - const body = (await res.json()) as { message?: string } - throw new Error(body.message ?? `Failed (${String(res.status)})`) + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `Failed (${String(res.status)})`) } setActionMessage({ type: 'success', text: 'Affiliate registered successfully' }) void fetchConfig(affiliateAddress) @@ -253,14 +253,14 @@ export const App = (): React.JSX.Element => { setActionLoading(true) clearActionMessage() try { - const res = await fetch(`${API_BASE}/claim-code`, { + const res = await fetch(`${AFFILIATE_URL}/claim-code`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify({ walletAddress: affiliateAddress, partnerCode: claimCode.trim() }), }) if (!res.ok) { - const body = (await res.json()) as { message?: string } - throw new Error(body.message ?? `Failed (${String(res.status)})`) + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `Failed (${String(res.status)})`) } setActionMessage({ type: 'success', text: `Partner code "${claimCode.trim()}" claimed` }) setClaimCode('') @@ -282,14 +282,14 @@ export const App = (): React.JSX.Element => { setActionLoading(true) clearActionMessage() try { - const res = await fetch(`${API_BASE}/${encodeURIComponent(affiliateAddress)}`, { + const res = await fetch(`${AFFILIATE_URL}/${encodeURIComponent(affiliateAddress)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify({ bps: parsedUpdateBps }), }) if (!res.ok) { - const body = (await res.json()) as { message?: string } - throw new Error(body.message ?? `Failed (${String(res.status)})`) + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `Failed (${String(res.status)})`) } setActionMessage({ type: 'success', text: `BPS updated to ${updateBps}` }) setUpdateBps('') @@ -313,14 +313,14 @@ export const App = (): React.JSX.Element => { setActionLoading(true) clearActionMessage() try { - const res = await fetch(`${API_BASE}/${encodeURIComponent(affiliateAddress)}`, { + const res = await fetch(`${AFFILIATE_URL}/${encodeURIComponent(affiliateAddress)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders }, body: JSON.stringify({ receiveAddress: updateReceiveAddress.trim() }), }) if (!res.ok) { - const body = (await res.json()) as { message?: string } - throw new Error(body.message ?? `Failed (${String(res.status)})`) + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `Failed (${String(res.status)})`) } setActionMessage({ type: 'success', text: 'Receive address updated' }) setUpdateReceiveAddress('') @@ -970,7 +970,7 @@ const styles: Record = { }, tabButtonActive: { color: '#f0f1f4', - borderBottomColor: '#386ff9', + borderBottom: '2px solid #386ff9', }, periodRow: { display: 'flex', diff --git a/packages/affiliate-dashboard/src/config/wagmi.ts b/packages/affiliate-dashboard/src/config/wagmi.ts index 7c77f8e861a..e8c47e7a3ae 100644 --- a/packages/affiliate-dashboard/src/config/wagmi.ts +++ b/packages/affiliate-dashboard/src/config/wagmi.ts @@ -2,7 +2,9 @@ import { arbitrum } from '@reown/appkit/networks' import { createAppKit } from '@reown/appkit/react' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' -const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || 'demo' +const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID + +if (!projectId) throw new Error('VITE_WALLETCONNECT_PROJECT_ID is not set') const metadata = { name: 'ShapeShift Affiliate Dashboard', diff --git a/packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts b/packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts index 51ec5379d48..3dbd4cf574e 100644 --- a/packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts +++ b/packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from 'react' -const API_BASE_URL = '/v1/affiliate' +const AFFILIATE_URL = `${import.meta.env.VITE_API_URL}/v1/affiliate` export interface AffiliateConfig { id: string @@ -38,7 +38,7 @@ export const useAffiliateConfig = (): UseAffiliateConfigReturn => { setError(null) try { - const response = await fetch(`${API_BASE_URL}/${encodeURIComponent(address)}`) + const response = await fetch(`${AFFILIATE_URL}/${encodeURIComponent(address)}`) // Stale response guard — discard if a newer request was fired if (currentRequestId !== requestIdRef.current) return diff --git a/packages/affiliate-dashboard/src/hooks/useAffiliateStats.ts b/packages/affiliate-dashboard/src/hooks/useAffiliateStats.ts index df5d261a134..06f5401c64c 100644 --- a/packages/affiliate-dashboard/src/hooks/useAffiliateStats.ts +++ b/packages/affiliate-dashboard/src/hooks/useAffiliateStats.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from 'react' -const API_BASE_URL = '/v1/affiliate/stats' +const AFFILIATE_STATS_URL = `${import.meta.env.VITE_API_URL}/v1/affiliate/stats` export interface AffiliateStats { totalSwaps: number @@ -52,7 +52,7 @@ export const useAffiliateStats = (): UseAffiliateStatsReturn => { if (options?.startDate) params.append('startDate', options.startDate) if (options?.endDate) params.append('endDate', options.endDate) - const response = await fetch(`${API_BASE_URL}?${params.toString()}`) + const response = await fetch(`${AFFILIATE_STATS_URL}?${params.toString()}`) if (!response.ok) { let errorMessage = `Request failed (${String(response.status)})` diff --git a/packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts b/packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts index 8de0ff8aaa4..eb1cfc81939 100644 --- a/packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts +++ b/packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from 'react' -const API_BASE_URL = '/v1/affiliate/swaps' +const AFFILIATE_SWAPS_URL = `${import.meta.env.VITE_API_URL}/v1/affiliate/swaps` export interface AffiliateSwap { id: string @@ -65,7 +65,7 @@ export const useAffiliateSwaps = (): UseAffiliateSwapsReturn => { if (options?.limit) params.append('limit', String(options.limit)) if (options?.offset) params.append('offset', String(options.offset)) - const response = await fetch(`${API_BASE_URL}?${params.toString()}`) + const response = await fetch(`${AFFILIATE_SWAPS_URL}?${params.toString()}`) if (!response.ok) { let errorMessage = `Request failed (${String(response.status)})` diff --git a/packages/affiliate-dashboard/src/hooks/useSiweAuth.ts b/packages/affiliate-dashboard/src/hooks/useSiweAuth.ts index 2269846727f..5cd412a40b9 100644 --- a/packages/affiliate-dashboard/src/hooks/useSiweAuth.ts +++ b/packages/affiliate-dashboard/src/hooks/useSiweAuth.ts @@ -2,7 +2,7 @@ import { useAppKitAccount } from '@reown/appkit/react' import { useCallback, useEffect, useState } from 'react' import { useSignMessage } from 'wagmi' -const API_BASE = '/v1/auth/siwe' +const AUTH_SIWE_URL = `${import.meta.env.VITE_API_URL}/v1/auth/siwe` interface SiweAuthState { token: string | null @@ -47,7 +47,7 @@ export const useSiweAuth = (): UseSiweAuthReturn => { setError(null) try { - const nonceRes = await fetch(`${API_BASE}/nonce`, { method: 'POST' }) + const nonceRes = await fetch(`${AUTH_SIWE_URL}/nonce`, { method: 'POST' }) if (!nonceRes.ok) throw new Error('Failed to get nonce') const { nonce } = (await nonceRes.json()) as { nonce: string } @@ -70,7 +70,7 @@ export const useSiweAuth = (): UseSiweAuthReturn => { const signature = await signMessageAsync({ message }) - const verifyRes = await fetch(`${API_BASE}/verify`, { + const verifyRes = await fetch(`${AUTH_SIWE_URL}/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, signature }), diff --git a/packages/affiliate-dashboard/src/main.tsx b/packages/affiliate-dashboard/src/main.tsx index 04b98e08658..03a9776a027 100644 --- a/packages/affiliate-dashboard/src/main.tsx +++ b/packages/affiliate-dashboard/src/main.tsx @@ -8,6 +8,8 @@ import { createConfig, http, WagmiProvider } from 'wagmi' import { App } from './App' +if (!import.meta.env.VITE_API_URL) throw new Error('VITE_API_URL is not set') + const queryClient = new QueryClient() const wagmiConfig = createConfig({ diff --git a/packages/affiliate-dashboard/tsconfig.json b/packages/affiliate-dashboard/tsconfig.json index 3934b8f6d67..250434313d5 100644 --- a/packages/affiliate-dashboard/tsconfig.json +++ b/packages/affiliate-dashboard/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", @@ -16,6 +17,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src", "vite.config.ts"] } diff --git a/packages/affiliate-dashboard/tsconfig.node.json b/packages/affiliate-dashboard/tsconfig.node.json deleted file mode 100644 index 42872c59f5b..00000000000 --- a/packages/affiliate-dashboard/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/packages/affiliate-dashboard/vite.config.ts b/packages/affiliate-dashboard/vite.config.ts index 11604459526..d3580ad0560 100644 --- a/packages/affiliate-dashboard/vite.config.ts +++ b/packages/affiliate-dashboard/vite.config.ts @@ -5,12 +5,6 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], server: { - port: 5175, - proxy: { - '/v1': { - target: process.env.VITE_API_URL || 'http://localhost:3005', - changeOrigin: true, - }, - }, + port: Number(process.env.PORT) || 5175, }, }) diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts index 7e20298dc5b..7d5e5ace94a 100644 --- a/packages/public-api/src/index.ts +++ b/packages/public-api/src/index.ts @@ -8,6 +8,7 @@ import { env } from './env' import { quoteStore } from './lib/quoteStore' import { resolvePartnerCode } from './middleware/auth' import { + affiliateMutationLimiter, affiliateStatsLimiter, dataLimiter, globalLimiter, @@ -15,8 +16,16 @@ import { swapRatesLimiter, swapStatusLimiter, } from './middleware/rateLimit' -import { getAffiliateStats } from './routes/affiliate' +import { + claimPartnerCode, + createAffiliate, + getAffiliate, + getAffiliateStats, + getAffiliateSwaps, + updateAffiliate, +} from './routes/affiliate' import { getAssetById, getAssetCount, getAssets } from './routes/assets' +import { siweNonce, siweVerify } from './routes/auth' import { getChainCount, getChains } from './routes/chains' import { getQuote } from './routes/quote' import { getRates } from './routes/rates' @@ -49,7 +58,15 @@ const startServer = async () => { v1Router.post('/swap/quote', swapQuoteLimiter, resolvePartnerCode, getQuote) v1Router.get('/swap/status', swapStatusLimiter, resolvePartnerCode, getSwapStatus) + v1Router.get('/affiliate/swaps', dataLimiter, getAffiliateSwaps) v1Router.get('/affiliate/stats', affiliateStatsLimiter, getAffiliateStats) + v1Router.get('/affiliate/:address', dataLimiter, getAffiliate) + v1Router.post('/affiliate/claim-code', affiliateMutationLimiter, claimPartnerCode) + v1Router.post('/affiliate', affiliateMutationLimiter, createAffiliate) + v1Router.patch('/affiliate/:address', affiliateMutationLimiter, updateAffiliate) + + v1Router.post('/auth/siwe/nonce', affiliateMutationLimiter, siweNonce) + v1Router.post('/auth/siwe/verify', affiliateMutationLimiter, siweVerify) v1Router.get('/chains', dataLimiter, getChains) v1Router.get('/chains/count', dataLimiter, getChainCount) diff --git a/packages/public-api/src/lib/fetchSwapService.ts b/packages/public-api/src/lib/fetchSwapService.ts new file mode 100644 index 00000000000..148d176f931 --- /dev/null +++ b/packages/public-api/src/lib/fetchSwapService.ts @@ -0,0 +1,34 @@ +import type { Response } from 'express' + +import type { ErrorResponse } from '../types' + +const DEFAULT_TIMEOUT_MS = 10_000 + +export const fetchSwapService = async ( + res: Response, + url: string, + options?: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...options, signal: controller.signal }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + res.status(504).json({ + error: 'Swap service request timed out', + code: 'SERVICE_TIMEOUT', + } satisfies ErrorResponse) + } else { + console.error('Failed to connect to swap-service:', error) + res.status(503).json({ + error: 'Swap service unavailable', + code: 'SERVICE_UNAVAILABLE', + } satisfies ErrorResponse) + } + return null + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/public-api/src/routes/affiliate/claimPartnerCode.ts b/packages/public-api/src/routes/affiliate/claimPartnerCode.ts new file mode 100644 index 00000000000..73f41df3b9d --- /dev/null +++ b/packages/public-api/src/routes/affiliate/claimPartnerCode.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { AffiliateConfigResponseSchema, ClaimPartnerCodeRequestSchema } from './types' + +registry.registerPath({ + method: 'post', + path: '/v1/affiliate/claim-code', + operationId: 'claimPartnerCode', + summary: 'Claim partner code', + description: + 'Claim a partner code for an affiliate. Requires a valid SIWE JWT in the Authorization header.', + tags: ['Affiliate'], + request: { + body: { + content: { 'application/json': { schema: ClaimPartnerCodeRequestSchema } }, + }, + }, + responses: { + 200: { + description: 'Affiliate configuration with claimed partner code', + content: { 'application/json': { schema: AffiliateConfigResponseSchema } }, + }, + 400: { description: 'Invalid request body' }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Forbidden' }, + 409: { description: 'Partner code already claimed' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const claimPartnerCode = async (req: Request, res: Response): Promise => { + try { + if (typeof req.headers.authorization !== 'string') { + res.status(401).json({ + error: 'Authorization header required', + code: 'UNAUTHORIZED', + } satisfies ErrorResponse) + return + } + + const bodyResult = ClaimPartnerCodeRequestSchema.safeParse(req.body) + if (!bodyResult.success) { + res.status(400).json({ + error: 'Invalid request body', + code: 'INVALID_REQUEST', + details: bodyResult.error.errors, + } satisfies ErrorResponse) + return + } + + const response = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/v1/affiliate/claim-code`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: req.headers.authorization, + }, + body: JSON.stringify(bodyResult.data), + }, + ) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = AffiliateConfigResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service POST /v1/affiliate/claim-code:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in claimPartnerCode:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/affiliate/createAffiliate.ts b/packages/public-api/src/routes/affiliate/createAffiliate.ts new file mode 100644 index 00000000000..f8b07166821 --- /dev/null +++ b/packages/public-api/src/routes/affiliate/createAffiliate.ts @@ -0,0 +1,98 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { AffiliateConfigResponseSchema, CreateAffiliateRequestSchema } from './types' + +registry.registerPath({ + method: 'post', + path: '/v1/affiliate', + operationId: 'createAffiliate', + summary: 'Create affiliate', + description: 'Register a new affiliate. Requires a valid SIWE JWT in the Authorization header.', + tags: ['Affiliate'], + request: { + body: { + content: { 'application/json': { schema: CreateAffiliateRequestSchema } }, + }, + }, + responses: { + 201: { + description: 'Affiliate created', + content: { 'application/json': { schema: AffiliateConfigResponseSchema } }, + }, + 400: { description: 'Invalid request body' }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Forbidden' }, + 409: { description: 'Affiliate already exists' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const createAffiliate = async (req: Request, res: Response): Promise => { + try { + if (typeof req.headers.authorization !== 'string') { + res.status(401).json({ + error: 'Authorization header required', + code: 'UNAUTHORIZED', + } satisfies ErrorResponse) + return + } + + const bodyResult = CreateAffiliateRequestSchema.safeParse(req.body) + if (!bodyResult.success) { + res.status(400).json({ + error: 'Invalid request body', + code: 'INVALID_REQUEST', + details: bodyResult.error.errors, + } satisfies ErrorResponse) + return + } + + const response = await fetchSwapService(res, `${env.SWAP_SERVICE_BASE_URL}/v1/affiliate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: req.headers.authorization, + }, + body: JSON.stringify(bodyResult.data), + }) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = AffiliateConfigResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service POST /v1/affiliate:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(201).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in createAffiliate:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/affiliate/getAffiliate.ts b/packages/public-api/src/routes/affiliate/getAffiliate.ts new file mode 100644 index 00000000000..bf1b9b42bd9 --- /dev/null +++ b/packages/public-api/src/routes/affiliate/getAffiliate.ts @@ -0,0 +1,82 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { AffiliateAddressParamsSchema, AffiliateConfigResponseSchema } from './types' + +registry.registerPath({ + method: 'get', + path: '/v1/affiliate/{address}', + operationId: 'getAffiliate', + summary: 'Get affiliate config', + description: 'Retrieve affiliate configuration for a given wallet address.', + tags: ['Affiliate'], + request: { + params: AffiliateAddressParamsSchema, + }, + responses: { + 200: { + description: 'Affiliate configuration', + content: { 'application/json': { schema: AffiliateConfigResponseSchema } }, + }, + 400: { description: 'Invalid request parameters' }, + 404: { description: 'Affiliate not found' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const getAffiliate = async (req: Request, res: Response): Promise => { + try { + const paramsResult = AffiliateAddressParamsSchema.safeParse(req.params) + if (!paramsResult.success) { + res.status(400).json({ + error: 'Invalid address format', + code: 'INVALID_REQUEST', + details: paramsResult.error.errors, + } satisfies ErrorResponse) + return + } + + const response = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/v1/affiliate/${paramsResult.data.address}`, + ) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = AffiliateConfigResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service /v1/affiliate/:address:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in getAffiliate:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/affiliate/getAffiliateStats.ts b/packages/public-api/src/routes/affiliate/getAffiliateStats.ts index 35728bd777b..99087779924 100644 --- a/packages/public-api/src/routes/affiliate/getAffiliateStats.ts +++ b/packages/public-api/src/routes/affiliate/getAffiliateStats.ts @@ -1,21 +1,16 @@ import type { Request, Response } from 'express' import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' import { registry } from '../../registry' import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' import type { AffiliateStatsResponse } from './types' -import { AffiliateStatsRequestSchema, AffiliateStatsResponseSchema } from './types' - -const AFFILIATE_TIMEOUT_MS = 10_000 - -// Backend response type from swap-service -type BackendAffiliateStats = { - affiliateAddress: string - swapCount: number - totalSwapVolumeUsd: string - totalFeesCollectedUsd: string - referrerCommissionUsd: string -} +import { + AffiliateFeeResponseSchema, + AffiliateStatsRequestSchema, + AffiliateStatsResponseSchema, +} from './types' registry.registerPath({ method: 'get', @@ -33,117 +28,81 @@ registry.registerPath({ description: 'Affiliate statistics', content: { 'application/json': { schema: AffiliateStatsResponseSchema } }, }, - 400: { - description: 'Invalid address format', - }, - 503: { - description: 'Swap service unavailable', - }, + 400: { description: 'Invalid request parameters' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, }, }) export const getAffiliateStats = async (req: Request, res: Response): Promise => { try { - // Parse and validate request - const parseResult = AffiliateStatsRequestSchema.safeParse(req.query) - if (!parseResult.success) { - const errorResponse: ErrorResponse = { + const queryResult = AffiliateStatsRequestSchema.safeParse(req.query) + if (!queryResult.success) { + res.status(400).json({ error: 'Invalid request parameters', code: 'INVALID_REQUEST', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: queryResult.error.errors, + } satisfies ErrorResponse) return } - const { address, startDate, endDate } = parseResult.data + const { address, startDate, endDate } = queryResult.data - // Build backend URL with query params - const backendUrl = new URL(`${env.SWAP_SERVICE_BASE_URL}/swaps/affiliate-fees/${address}`) - if (startDate) { - backendUrl.searchParams.append('startDate', String(startDate)) - } - if (endDate) { - backendUrl.searchParams.append('endDate', String(endDate)) - } + const url = new URL(`${env.SWAP_SERVICE_BASE_URL}/swaps/affiliate-fees/${address}`) - // Call backend swap-service - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), AFFILIATE_TIMEOUT_MS) - let backendResponse: globalThis.Response - try { - backendResponse = await fetch(backendUrl.toString(), { - signal: controller.signal, - }) - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - res.status(504).json({ - error: 'Swap service request timed out', - code: 'SERVICE_TIMEOUT', - } as ErrorResponse) - return - } - console.error('Failed to connect to swap-service:', error) - res.status(503).json({ - error: 'Swap service unavailable', - code: 'SERVICE_UNAVAILABLE', - } as ErrorResponse) - return - } finally { - clearTimeout(timeout) - } + if (startDate) url.searchParams.append('startDate', String(startDate)) + if (endDate) url.searchParams.append('endDate', String(endDate)) + + const response = await fetchSwapService(res, url.toString()) + if (!response) return - // Handle backend errors - if (!backendResponse.ok) { - if (backendResponse.status === 404) { - // Non-existent affiliate - return 200 with zero values - const response: AffiliateStatsResponse = { + if (!response.ok) { + if (response.status === 404) { + res.status(200).json({ address, totalSwaps: 0, totalVolumeUsd: '0.00', totalFeesEarnedUsd: '0.00', timestamp: Date.now(), - } - res.status(200).json(response) + } satisfies AffiliateStatsResponse) return } - console.error(`Backend returned ${backendResponse.status}:`, await backendResponse.text()) - res.status(503).json({ - error: 'Swap service error', - code: 'SERVICE_ERROR', - } as ErrorResponse) + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) return } - // Parse backend response - let backendData: BackendAffiliateStats - try { - backendData = (await backendResponse.json()) as BackendAffiliateStats - } catch (error) { - console.error('Failed to parse backend response:', error) + const responseResult = AffiliateFeeResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service /swaps/affiliate-fees:', + responseResult.error.errors, + ) res.status(503).json({ error: 'Invalid response from swap service', code: 'INVALID_RESPONSE', - } as ErrorResponse) + } satisfies ErrorResponse) return } - // Transform backend response to public API format - const response: AffiliateStatsResponse = { - address: String(backendData.affiliateAddress), - totalSwaps: backendData.swapCount, - totalVolumeUsd: backendData.totalSwapVolumeUsd, - totalFeesEarnedUsd: backendData.referrerCommissionUsd, + res.status(200).json({ + address: responseResult.data.affiliateAddress, + totalSwaps: responseResult.data.swapCount, + totalVolumeUsd: responseResult.data.totalSwapVolumeUsd, + totalFeesEarnedUsd: responseResult.data.referrerCommissionUsd, timestamp: Date.now(), - } - - res.status(200).json(response) + } satisfies AffiliateStatsResponse) } catch (error) { console.error('Unexpected error in getAffiliateStats:', error) res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR', - } as ErrorResponse) + } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/affiliate/getAffiliateSwaps.ts b/packages/public-api/src/routes/affiliate/getAffiliateSwaps.ts new file mode 100644 index 00000000000..343427666ea --- /dev/null +++ b/packages/public-api/src/routes/affiliate/getAffiliateSwaps.ts @@ -0,0 +1,89 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { AffiliateSwapsRequestSchema, AffiliateSwapsResponseSchema } from './types' + +registry.registerPath({ + method: 'get', + path: '/v1/affiliate/swaps', + operationId: 'getAffiliateSwaps', + summary: 'Get affiliate swaps', + description: + 'Retrieve paginated swap history for an affiliate address. Supports optional date range filtering.', + tags: ['Affiliate'], + request: { + query: AffiliateSwapsRequestSchema, + }, + responses: { + 200: { + description: 'Affiliate swaps', + content: { 'application/json': { schema: AffiliateSwapsResponseSchema } }, + }, + 400: { description: 'Invalid query parameters' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const getAffiliateSwaps = async (req: Request, res: Response): Promise => { + try { + const queryResult = AffiliateSwapsRequestSchema.safeParse(req.query) + if (!queryResult.success) { + res.status(400).json({ + error: 'Invalid request parameters', + code: 'INVALID_REQUEST', + details: queryResult.error.errors, + } satisfies ErrorResponse) + return + } + + const { address, startDate, endDate, limit, offset } = queryResult.data + + const url = new URL(`${env.SWAP_SERVICE_BASE_URL}/v1/affiliate/swaps`) + + url.searchParams.append('address', address) + url.searchParams.append('limit', String(limit)) + url.searchParams.append('offset', String(offset)) + + if (startDate) url.searchParams.append('startDate', startDate) + if (endDate) url.searchParams.append('endDate', endDate) + + const response = await fetchSwapService(res, url.toString()) + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = AffiliateSwapsResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service /v1/affiliate/swaps:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in getAffiliateSwaps:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/affiliate/index.ts b/packages/public-api/src/routes/affiliate/index.ts index 44cffecd9a2..f3a79e67d10 100644 --- a/packages/public-api/src/routes/affiliate/index.ts +++ b/packages/public-api/src/routes/affiliate/index.ts @@ -1 +1,6 @@ +export * from './claimPartnerCode' +export * from './createAffiliate' +export * from './getAffiliate' export * from './getAffiliateStats' +export * from './getAffiliateSwaps' +export * from './updateAffiliate' diff --git a/packages/public-api/src/routes/affiliate/types.ts b/packages/public-api/src/routes/affiliate/types.ts index c19c0960080..27a57315a2b 100644 --- a/packages/public-api/src/routes/affiliate/types.ts +++ b/packages/public-api/src/routes/affiliate/types.ts @@ -1,15 +1,107 @@ import { z } from 'zod' import { registry } from '../../registry' +import { EVM_ADDRESS } from '../../types' +import { AssetSchema } from '../assets/types' + +// --- Affiliate Config --- + +export const AffiliateAddressParamsSchema = z.object({ + address: EVM_ADDRESS, +}) + +export const AffiliateConfigResponseSchema = registry.register( + 'AffiliateConfig', + z.object({ + id: z.string().openapi({ example: 'abc123' }), + walletAddress: EVM_ADDRESS, + receiveAddress: EVM_ADDRESS.nullable(), + partnerCode: z.string().nullable().openapi({ example: 'mypartner' }), + bps: z.number().openapi({ example: 30 }), + isActive: z.boolean().openapi({ example: true }), + createdAt: z.string().openapi({ example: '2024-01-01T00:00:00.000Z' }), + updatedAt: z.string().openapi({ example: '2024-01-01T00:00:00.000Z' }), + }), +) + +export const CreateAffiliateRequestSchema = z.object({ + walletAddress: EVM_ADDRESS, + receiveAddress: EVM_ADDRESS.optional(), + partnerCode: z.string().trim().min(1, 'partnerCode must not be empty').optional(), + bps: z.number().int().min(0).optional(), +}) + +export const UpdateAffiliateRequestSchema = z.object({ + receiveAddress: EVM_ADDRESS.optional(), + bps: z.number().int().min(0).optional(), +}) + +export const ClaimPartnerCodeRequestSchema = z.object({ + walletAddress: EVM_ADDRESS, + partnerCode: z.string().trim().min(1, 'partnerCode must not be empty'), +}) + +// --- Affiliate Swaps --- + +export const AffiliateSwapItemSchema = registry.register( + 'AffiliateSwapItem', + z.object({ + swapId: z.string().openapi({ example: 'swap-uuid-1234' }), + status: z.string().openapi({ example: 'completed' }), + sellAsset: AssetSchema, + buyAsset: AssetSchema, + sellAmountCryptoPrecision: z.string().openapi({ example: '1000000000000000000' }), + expectedBuyAmountCryptoPrecision: z.string().openapi({ example: '950000000' }), + actualBuyAmountCryptoPrecision: z.string().nullable().openapi({ example: '948000000' }), + sellAmountUsd: z.string().nullable().openapi({ example: '1234.56' }), + affiliateBps: z.string().nullable().openapi({ example: '30' }), + affiliateFeeUsd: z.string().nullable().openapi({ example: '3.70' }), + swapperName: z.string().openapi({ example: 'THORChain' }), + sellTxHash: z.string().nullable().openapi({ example: '0xabc123' }), + createdAt: z.string().openapi({ example: '2024-01-01T00:00:00.000Z' }), + }), +) + +export const AffiliateSwapsRequestSchema = z + .object({ + address: EVM_ADDRESS, + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .refine( + ({ startDate, endDate }) => + !startDate || !endDate || new Date(startDate).getTime() <= new Date(endDate).getTime(), + { + message: 'startDate must be before or equal to endDate', + path: ['startDate'], + }, + ) + +export const AffiliateSwapsResponseSchema = registry.register( + 'AffiliateSwapsResponse', + z.object({ + swaps: z.array(AffiliateSwapItemSchema), + total: z.number().openapi({ example: 100 }), + limit: z.number().openapi({ example: 50 }), + offset: z.number().openapi({ example: 0 }), + }), +) + +// --- Affiliate Stats --- + +export const AffiliateFeeResponseSchema = z.object({ + affiliateAddress: EVM_ADDRESS, + swapCount: z.number(), + totalSwapVolumeUsd: z.string(), + totalFeesCollectedUsd: z.string(), + referrerCommissionUsd: z.string(), +}) export const AffiliateStatsRequestSchema = z .object({ - address: z - .string() - .regex( - /^0x[0-9a-fA-F]{40}$/, - 'address must be a valid EVM address (0x followed by 40 hex characters)', - ), + address: EVM_ADDRESS, startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), }) @@ -25,7 +117,7 @@ export const AffiliateStatsRequestSchema = z export const AffiliateStatsResponseSchema = registry.register( 'AffiliateStatsResponse', z.object({ - address: z.string().openapi({ example: '0x1234567890123456789012345678901234567890' }), + address: EVM_ADDRESS, totalSwaps: z.number().openapi({ example: 42 }), totalVolumeUsd: z.string().openapi({ example: '12345.67' }), totalFeesEarnedUsd: z.string().openapi({ example: '44.44' }), @@ -33,5 +125,15 @@ export const AffiliateStatsResponseSchema = registry.register( }), ) +// --- Inferred types --- + +export type AffiliateAddressParams = z.infer +export type AffiliateConfig = z.infer +export type CreateAffiliateRequest = z.infer +export type UpdateAffiliateRequest = z.infer +export type ClaimPartnerCodeRequest = z.infer +export type AffiliateSwapItem = z.infer +export type AffiliateSwapsRequest = z.infer +export type AffiliateSwapsResponse = z.infer export type AffiliateStatsRequest = z.infer export type AffiliateStatsResponse = z.infer diff --git a/packages/public-api/src/routes/affiliate/updateAffiliate.ts b/packages/public-api/src/routes/affiliate/updateAffiliate.ts new file mode 100644 index 00000000000..55951ecb077 --- /dev/null +++ b/packages/public-api/src/routes/affiliate/updateAffiliate.ts @@ -0,0 +1,118 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { + AffiliateAddressParamsSchema, + AffiliateConfigResponseSchema, + UpdateAffiliateRequestSchema, +} from './types' + +registry.registerPath({ + method: 'patch', + path: '/v1/affiliate/{address}', + operationId: 'updateAffiliate', + summary: 'Update affiliate', + description: + 'Update an existing affiliate configuration. Requires a valid SIWE JWT in the Authorization header.', + tags: ['Affiliate'], + request: { + params: AffiliateAddressParamsSchema, + body: { + content: { 'application/json': { schema: UpdateAffiliateRequestSchema } }, + }, + }, + responses: { + 200: { + description: 'Updated affiliate configuration', + content: { 'application/json': { schema: AffiliateConfigResponseSchema } }, + }, + 400: { description: 'Invalid request' }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Forbidden' }, + 404: { description: 'Affiliate not found' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const updateAffiliate = async (req: Request, res: Response): Promise => { + try { + if (typeof req.headers.authorization !== 'string') { + res.status(401).json({ + error: 'Authorization header required', + code: 'UNAUTHORIZED', + } satisfies ErrorResponse) + return + } + + const paramsResult = AffiliateAddressParamsSchema.safeParse(req.params) + if (!paramsResult.success) { + res.status(400).json({ + error: 'Invalid address format', + code: 'INVALID_REQUEST', + details: paramsResult.error.errors, + } satisfies ErrorResponse) + return + } + + const bodyResult = UpdateAffiliateRequestSchema.safeParse(req.body) + if (!bodyResult.success) { + res.status(400).json({ + error: 'Invalid request body', + code: 'INVALID_REQUEST', + details: bodyResult.error.errors, + } satisfies ErrorResponse) + return + } + + const response = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/v1/affiliate/${paramsResult.data.address}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: req.headers.authorization, + }, + body: JSON.stringify(bodyResult.data), + }, + ) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = AffiliateConfigResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service PATCH /v1/affiliate/:address:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in updateAffiliate:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/assets/getAssetById.ts b/packages/public-api/src/routes/assets/getAssetById.ts index 25bd94bda06..d40cb8645d0 100644 --- a/packages/public-api/src/routes/assets/getAssetById.ts +++ b/packages/public-api/src/routes/assets/getAssetById.ts @@ -21,48 +21,48 @@ registry.registerPath({ description: 'Asset details', content: { 'application/json': { schema: AssetSchema } }, }, - 404: { - description: 'Asset not found', - }, + 400: { description: 'Invalid asset ID' }, + 404: { description: 'Asset not found' }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) export const getAssetById = (req: Request, res: Response): void => { try { - const parseResult = AssetRequestSchema.safeParse(req.params) - if (!parseResult.success) { - const errorResponse: ErrorResponse = { + const paramsResult = AssetRequestSchema.safeParse(req.params) + if (!paramsResult.success) { + res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: paramsResult.error.errors, + } satisfies ErrorResponse) return } - const { assetId } = parseResult.data - // URL decode the assetId since it contains special characters - let decodedAssetId: string - try { - decodedAssetId = decodeURIComponent(assetId) - } catch { - const errorResponse: ErrorResponse = { - error: 'Invalid URL encoding for assetId', - details: { assetId }, + const { assetId } = paramsResult.data + + const decodedAssetId = (() => { + try { + return decodeURIComponent(assetId) + } catch { + res.status(400).json({ + error: 'Invalid URL encoding for assetId', + details: { assetId }, + } satisfies ErrorResponse) } - res.status(400).json(errorResponse) - return - } - const asset = getAsset(decodedAssetId) + })() + if (!decodedAssetId) return + + const asset = getAsset(decodedAssetId) if (!asset) { - res.status(404).json({ error: `Asset not found: ${decodedAssetId}` } as ErrorResponse) + res.status(404).json({ error: `Asset not found: ${decodedAssetId}` } satisfies ErrorResponse) return } res.json(asset) } catch (error) { console.error('Error in getAssetById:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/assets/getAssetCount.ts b/packages/public-api/src/routes/assets/getAssetCount.ts index 91ad727fbe6..032e5e5fcd2 100644 --- a/packages/public-api/src/routes/assets/getAssetCount.ts +++ b/packages/public-api/src/routes/assets/getAssetCount.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express' import { getAllAssets } from '../../assets' import { registry } from '../../registry' import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' import type { AssetCountResponse } from './types' import { AssetCountRequestSchema, AssetCountResponseSchema } from './types' @@ -21,33 +22,30 @@ registry.registerPath({ description: 'Asset count', content: { 'application/json': { schema: AssetCountResponseSchema } }, }, + 400: { description: 'Invalid query parameters' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) export const getAssetCount = (req: Request, res: Response): void => { try { - const parseResult = AssetCountRequestSchema.safeParse(req.query) - if (!parseResult.success) { - const errorResponse: ErrorResponse = { + const queryResult = AssetCountRequestSchema.safeParse(req.query) + if (!queryResult.success) { + res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: queryResult.error.errors, + } satisfies ErrorResponse) return } - const { chainId } = parseResult.data + const { chainId } = queryResult.data const assets = getAllAssets() const count = chainId ? assets.filter(a => a.chainId === chainId).length : assets.length - const response: AssetCountResponse = { - count, - timestamp: Date.now(), - } - - res.json(response) + res.json({ count, timestamp: Date.now() } satisfies AssetCountResponse) } catch (error) { console.error('Error in getAssetCount:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/assets/getAssets.ts b/packages/public-api/src/routes/assets/getAssets.ts index b047c8c9a27..ec682d2cff4 100644 --- a/packages/public-api/src/routes/assets/getAssets.ts +++ b/packages/public-api/src/routes/assets/getAssets.ts @@ -22,42 +22,32 @@ registry.registerPath({ description: 'List of assets', content: { 'application/json': { schema: AssetsListResponseSchema } }, }, + 400: { description: 'Invalid query parameters' }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) export const getAssets = (req: Request, res: Response): void => { try { - const parseResult = AssetsListRequestSchema.safeParse(req.query) - if (!parseResult.success) { - const errorResponse: ErrorResponse = { + const queryResult = AssetsListRequestSchema.safeParse(req.query) + if (!queryResult.success) { + res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: queryResult.error.errors, + } satisfies ErrorResponse) return } - const { chainId, limit, offset } = parseResult.data + const { chainId, limit, offset } = queryResult.data - let assets = getAllAssets() + const assets = getAllAssets() + const filteredAssets = chainId ? assets.filter(asset => asset.chainId === chainId) : assets + const paginatedAssets = filteredAssets.slice(offset, offset + limit) - // 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: AssetsListResponse = { - assets: paginatedAssets, - timestamp: Date.now(), - } - - res.json(response) + res.json({ assets: paginatedAssets, timestamp: Date.now() } satisfies AssetsListResponse) } catch (error) { console.error('Error in getAssets:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/assets/types.ts b/packages/public-api/src/routes/assets/types.ts index ff43dfe9b85..7bdc71b078d 100644 --- a/packages/public-api/src/routes/assets/types.ts +++ b/packages/public-api/src/routes/assets/types.ts @@ -18,7 +18,7 @@ export const AssetSchema: z.ZodType = 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/' }), - relatedAssetKey: z.string(), + relatedAssetKey: z.string().nullable(), }), ) diff --git a/packages/public-api/src/routes/auth/index.ts b/packages/public-api/src/routes/auth/index.ts new file mode 100644 index 00000000000..fd64e6e2bde --- /dev/null +++ b/packages/public-api/src/routes/auth/index.ts @@ -0,0 +1,2 @@ +export * from './siweNonce' +export * from './siweVerify' diff --git a/packages/public-api/src/routes/auth/siweNonce.ts b/packages/public-api/src/routes/auth/siweNonce.ts new file mode 100644 index 00000000000..724ea2424a8 --- /dev/null +++ b/packages/public-api/src/routes/auth/siweNonce.ts @@ -0,0 +1,68 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { SiweNonceResponseSchema } from './types' + +registry.registerPath({ + method: 'post', + path: '/v1/auth/siwe/nonce', + operationId: 'siweNonce', + summary: 'Get SIWE nonce', + description: 'Request a nonce for Sign-In with Ethereum (SIWE) authentication.', + tags: ['Auth'], + responses: { + 200: { + description: 'SIWE nonce', + content: { 'application/json': { schema: SiweNonceResponseSchema } }, + }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const siweNonce = async (_req: Request, res: Response): Promise => { + try { + const response = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/v1/auth/siwe/nonce`, + { method: 'POST' }, + ) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = SiweNonceResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service /siwe/nonce:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in siweNonce:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/auth/siweVerify.ts b/packages/public-api/src/routes/auth/siweVerify.ts new file mode 100644 index 00000000000..b89a252ee57 --- /dev/null +++ b/packages/public-api/src/routes/auth/siweVerify.ts @@ -0,0 +1,90 @@ +import type { Request, Response } from 'express' + +import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' +import { registry } from '../../registry' +import type { ErrorResponse } from '../../types' +import { rateLimitResponse } from '../../types' +import { SiweVerifyRequestSchema, SiweVerifyResponseSchema } from './types' + +registry.registerPath({ + method: 'post', + path: '/v1/auth/siwe/verify', + operationId: 'siweVerify', + summary: 'Verify SIWE signature', + description: + 'Verify a Sign-In with Ethereum (SIWE) message and signature. Returns a JWT token on success.', + tags: ['Auth'], + request: { + body: { + content: { 'application/json': { schema: SiweVerifyRequestSchema } }, + }, + }, + responses: { + 200: { + description: 'Authentication successful', + content: { 'application/json': { schema: SiweVerifyResponseSchema } }, + }, + 400: { description: 'Invalid request body' }, + 401: { description: 'Invalid signature' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, + }, +}) + +export const siweVerify = async (req: Request, res: Response): Promise => { + try { + const bodyResult = SiweVerifyRequestSchema.safeParse(req.body) + if (!bodyResult.success) { + res.status(400).json({ + error: 'Invalid request body', + code: 'INVALID_REQUEST', + details: bodyResult.error.errors, + } satisfies ErrorResponse) + return + } + + const response = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/v1/auth/siwe/verify`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyResult.data), + }, + ) + + if (!response) return + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: 'Upstream error' })) + res.status(response.status).json(body) + return + } + + const responseResult = SiweVerifyResponseSchema.safeParse( + await response.json().catch(() => null), + ) + + if (!responseResult.success) { + console.error( + 'Unexpected response shape from swap-service /siwe/verify:', + responseResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + res.status(200).json(responseResult.data) + } catch (error) { + console.error('Unexpected error in siweVerify:', error) + res + .status(500) + .json({ error: 'Internal server error', code: 'INTERNAL_ERROR' } satisfies ErrorResponse) + } +} diff --git a/packages/public-api/src/routes/auth/types.ts b/packages/public-api/src/routes/auth/types.ts new file mode 100644 index 00000000000..fbab5f22f69 --- /dev/null +++ b/packages/public-api/src/routes/auth/types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +import { registry } from '../../registry' + +export const SiweVerifyRequestSchema = z.object({ + message: z.string(), + signature: z.string(), +}) + +export const SiweNonceResponseSchema = registry.register( + 'SiweNonceResponse', + z.object({ + nonce: z.string().openapi({ example: 'abcdef123456' }), + }), +) + +export const SiweVerifyResponseSchema = registry.register( + 'SiweVerifyResponse', + z.object({ + token: z.string().openapi({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }), + address: z.string().openapi({ example: '0x1234567890123456789012345678901234567890' }), + }), +) + +export type SiweVerifyRequest = z.infer +export type SiweNonceResponse = z.infer +export type SiweVerifyResponse = z.infer diff --git a/packages/public-api/src/routes/chains/getChainCount.ts b/packages/public-api/src/routes/chains/getChainCount.ts index af9f9e6b166..24d31445b1c 100644 --- a/packages/public-api/src/routes/chains/getChainCount.ts +++ b/packages/public-api/src/routes/chains/getChainCount.ts @@ -20,21 +20,16 @@ registry.registerPath({ content: { 'application/json': { schema: ChainCountResponseSchema } }, }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) export const getChainCount = (_req: Request, res: Response): void => { try { const count = getChainList().length - - const response: ChainCountResponse = { - count, - timestamp: Date.now(), - } - - res.json(response) + res.json({ count, timestamp: Date.now() } satisfies ChainCountResponse) } catch (error) { console.error('Error in getChainCount:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/chains/getChains.ts b/packages/public-api/src/routes/chains/getChains.ts index bc1cf921619..6a47e3e6688 100644 --- a/packages/public-api/src/routes/chains/getChains.ts +++ b/packages/public-api/src/routes/chains/getChains.ts @@ -20,21 +20,16 @@ registry.registerPath({ content: { 'application/json': { schema: ChainsListResponseSchema } }, }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) export const getChains = (_req: Request, res: Response): void => { try { const chains = getChainList() - - const response: ChainsListResponse = { - chains, - timestamp: Date.now(), - } - - res.json(response) + res.json({ chains, timestamp: Date.now() } satisfies ChainsListResponse) } catch (error) { console.error('Error in getChains:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 7d7a75fec1e..e8588dc5ca5 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -1,4 +1,4 @@ -import type { GetTradeQuoteInputWithWallet, TradeQuote } from '@shapeshiftoss/swapper' +import type { GetTradeQuoteInputWithWallet } from '@shapeshiftoss/swapper' import { getDefaultSlippageDecimalPercentageForSwapper, getTradeQuotes, @@ -40,20 +40,20 @@ registry.registerPath({ 400: { description: 'Invalid request or unavailable swapper', }, + 404: { description: 'No quote available' }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) 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 = { + const bodyResult = QuoteRequestSchema.safeParse(req.body) + if (!bodyResult.success) { + res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: bodyResult.error.errors, + } satisfies ErrorResponse) return } @@ -67,53 +67,46 @@ export const getQuote = async (req: Request, res: Response): Promise => { slippageTolerancePercentageDecimal, allowMultiHop, accountNumber, - } = parseResult.data + } = bodyResult.data - // Validate swapper name - const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) as - | SwapperName - | undefined + const validSwapperName = Object.values(SwapperName).find(v => v === swapperName) if (!validSwapperName) { - res.status(400).json({ error: `Unknown swapper: ${swapperName}` } as ErrorResponse) + res.status(400).json({ error: `Unknown swapper: ${swapperName}` } satisfies ErrorResponse) return } - // Validate swapper exists const swapper = swappers[validSwapperName] if (!swapper) { res.status(400).json({ error: `Swapper not available: ${swapperName}`, - } as ErrorResponse) + } satisfies 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) + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } satisfies ErrorResponse) return } + + const buyAsset = getAsset(buyAssetId) if (!buyAsset) { - res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } satisfies ErrorResponse) return } - // Create swapper dependencies const deps = getSwapperDeps() - // Get default slippage if not provided - let slippage = slippageTolerancePercentageDecimal - if (!slippage) { + const slippage = (() => { + if (slippageTolerancePercentageDecimal) return slippageTolerancePercentageDecimal + try { - slippage = getDefaultSlippageDecimalPercentageForSwapper(validSwapperName) + return getDefaultSlippageDecimalPercentageForSwapper(validSwapperName) } catch { - slippage = '0.01' // 1% default fallback + return '0.01' // 1% default fallback } - } + })() - // Build quote input const quoteInput = { sellAsset, buyAsset, @@ -127,11 +120,8 @@ export const getQuote = async (req: Request, res: Response): Promise => { accountNumber, quoteOrRate: 'quote' as const, chainId: sellAsset.chainId, - // EVM-specific fields - supportsEIP1559: true, } - // Fetch quote const result = await getTradeQuotes( quoteInput as GetTradeQuoteInputWithWallet, validSwapperName, @@ -141,7 +131,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { if (!result) { res.status(404).json({ error: 'No quote available from this swapper', - } as ErrorResponse) + } satisfies ErrorResponse) return } @@ -151,18 +141,18 @@ export const getQuote = async (req: Request, res: Response): Promise => { error: error.message, code: error.code, details: error.details, - } as ErrorResponse) + } satisfies ErrorResponse) return } const quotes = result.unwrap() if (quotes.length === 0) { - res.status(404).json({ error: 'No quote available' } as ErrorResponse) + res.status(404).json({ error: 'No quote available' } satisfies ErrorResponse) return } // Use the first/best quote - const quote = quotes[0] as TradeQuote + const quote = quotes[0] const firstStep = quote.steps[0] // Calculate total buy amount (sum of all steps for multi-hop) @@ -202,6 +192,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { res.status(depositContextResult.statusCode).json(depositContextResult.error) return } + const { context: depositContext } = depositContextResult const response: QuoteResponse = { @@ -228,6 +219,6 @@ export const getQuote = async (req: Request, res: Response): Promise => { res.json(response) } catch (error) { console.error('Error in getQuote:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/rates/getRates.ts b/packages/public-api/src/routes/rates/getRates.ts index 62b89869765..328cce351b2 100644 --- a/packages/public-api/src/routes/rates/getRates.ts +++ b/packages/public-api/src/routes/rates/getRates.ts @@ -47,19 +47,18 @@ registry.registerPath({ description: 'Invalid request', }, 429: rateLimitResponse, + 500: { description: 'Internal server error' }, }, }) 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 = { + const queryResult = RatesRequestSchema.safeParse(req.query) + if (!queryResult.success) { + res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } - res.status(400).json(errorResponse) + details: queryResult.error.errors, + } satisfies ErrorResponse) return } @@ -69,25 +68,22 @@ export const getRates = async (req: Request, res: Response): Promise => { sellAmountCryptoBaseUnit, slippageTolerancePercentageDecimal, allowMultiHop, - } = parseResult.data + } = queryResult.data - // Get assets const sellAsset = getAsset(sellAssetId) - const buyAsset = getAsset(buyAssetId) - if (!sellAsset) { - res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } as ErrorResponse) + res.status(400).json({ error: `Unknown sell asset: ${sellAssetId}` } satisfies ErrorResponse) return } + + const buyAsset = getAsset(buyAssetId) if (!buyAsset) { - res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } as ErrorResponse) + res.status(400).json({ error: `Unknown buy asset: ${buyAssetId}` } satisfies ErrorResponse) return } - // Create swapper dependencies const deps = getSwapperDeps() - // Build rate input const rateInput = { sellAsset, buyAsset, @@ -103,13 +99,11 @@ export const getRates = async (req: Request, res: Response): Promise => { 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] @@ -189,6 +183,7 @@ export const getRates = async (req: Request, res: Response): Promise => { }) const now = Date.now() + const response: RateResponse = { rates, timestamp: now, @@ -199,6 +194,6 @@ export const getRates = async (req: Request, res: Response): Promise => { res.json(response) } catch (error) { console.error('Error in getRates:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/status/getSwapStatus.ts b/packages/public-api/src/routes/status/getSwapStatus.ts index 8965daefdb3..b52d42714ee 100644 --- a/packages/public-api/src/routes/status/getSwapStatus.ts +++ b/packages/public-api/src/routes/status/getSwapStatus.ts @@ -1,13 +1,14 @@ import type { Request, Response } from 'express' import { env } from '../../env' +import { fetchSwapService } from '../../lib/fetchSwapService' import { quoteStore } from '../../lib/quoteStore' import { registry } from '../../registry' import type { ErrorResponse } from '../../types' -import { PartnerCodeHeaderSchema } from '../../types' +import { PartnerCodeHeaderSchema, rateLimitResponse } from '../../types' import { STATUS_TIMEOUT_MS } from './constants' -import type { SwapServiceStatus, SwapStatusResponse } from './types' -import { StatusRequestSchema, SwapStatusResponseSchema } from './types' +import type { SwapStatusResponse } from './types' +import { StatusRequestSchema, SwapServiceStatusSchema, SwapStatusResponseSchema } from './types' import { registerSwapInService } from './utils' registry.registerPath({ @@ -27,30 +28,28 @@ registry.registerPath({ description: 'Swap status', content: { 'application/json': { schema: SwapStatusResponseSchema } }, }, - 400: { - description: 'Invalid request parameters', - }, - 404: { - description: 'Quote not found or expired', - }, - 409: { - description: 'Transaction hash mismatch', - }, + 400: { description: 'Invalid request parameters or txHash required to begin tracking' }, + 404: { description: 'Quote not found or expired' }, + 409: { description: 'Transaction hash mismatch' }, + 429: rateLimitResponse, + 500: { description: 'Internal server error' }, + 503: { description: 'Swap service unavailable' }, + 504: { description: 'Swap service timed out' }, }, }) export const getSwapStatus = async (req: Request, res: Response): Promise => { try { - const parseResult = StatusRequestSchema.safeParse(req.query) - if (!parseResult.success) { + const queryResult = StatusRequestSchema.safeParse(req.query) + if (!queryResult.success) { res.status(400).json({ error: 'Invalid request parameters', - details: parseResult.error.errors, - } as ErrorResponse) + details: queryResult.error.errors, + } satisfies ErrorResponse) return } - const { quoteId, txHash } = parseResult.data + const { quoteId, txHash } = queryResult.data const storedQuote = quoteStore.get(quoteId) @@ -58,7 +57,15 @@ export const getSwapStatus = async (req: Request, res: Response): Promise res.status(404).json({ error: 'Quote not found or expired', code: 'QUOTE_NOT_FOUND', - } as ErrorResponse) + } satisfies ErrorResponse) + return + } + + if (!txHash && !storedQuote.txHash) { + res.status(400).json({ + error: 'txHash is required to begin tracking', + code: 'TX_HASH_REQUIRED', + } satisfies ErrorResponse) return } @@ -66,74 +73,14 @@ export const getSwapStatus = async (req: Request, res: Response): Promise res.status(409).json({ error: 'Transaction hash does not match the registered swap', code: 'TX_HASH_MISMATCH', - } as ErrorResponse) + } satisfies ErrorResponse) return } - if (txHash && !storedQuote.txHash) { - // Defense-in-depth: re-read from store before mutation (future-proofing for potential async operations above) - const current = quoteStore.get(quoteId) - if (current?.txHash) { - res.json({ - quoteId, - txHash: current.txHash, - status: current.status, - swapperName: current.swapperName, - sellAssetId: current.sellAssetId, - buyAssetId: current.buyAssetId, - sellAmountCryptoBaseUnit: current.sellAmountCryptoBaseUnit, - buyAmountAfterFeesCryptoBaseUnit: current.buyAmountAfterFeesCryptoBaseUnit, - affiliateAddress: current.affiliateAddress, - affiliateBps: current.affiliateBps, - registeredAt: current.registeredAt, - }) - return - } - - storedQuote.txHash = txHash - storedQuote.registeredAt = Date.now() - storedQuote.status = 'submitted' - quoteStore.set(quoteId, storedQuote) - - await registerSwapInService(storedQuote) - } - - let swapServiceStatus: SwapServiceStatus | null = null - if (storedQuote.txHash) { - const getController = new AbortController() - const getTimeout = setTimeout(() => getController.abort(), STATUS_TIMEOUT_MS) - try { - const swapResponse = await fetch(`${env.SWAP_SERVICE_BASE_URL}/swaps/${quoteId}`, { - signal: getController.signal, - }) - if (swapResponse.ok) { - swapServiceStatus = (await swapResponse.json()) as SwapServiceStatus - } else if (swapResponse.status === 404) { - await registerSwapInService(storedQuote) - } - } catch (err) { - console.error('Failed to fetch swap status from swap-service:', err) - } finally { - clearTimeout(getTimeout) - } - } - - const status = - swapServiceStatus?.status === 'SUCCESS' - ? 'confirmed' - : swapServiceStatus?.status === 'FAILED' - ? 'failed' - : storedQuote.status - - if (status !== storedQuote.status && (status === 'confirmed' || status === 'failed')) { - storedQuote.status = status - quoteStore.set(quoteId, storedQuote) - } - const response: SwapStatusResponse = { quoteId, txHash: storedQuote.txHash, - status, + status: storedQuote.status, swapperName: storedQuote.swapperName, sellAssetId: storedQuote.sellAssetId, buyAssetId: storedQuote.buyAssetId, @@ -144,16 +91,81 @@ export const getSwapStatus = async (req: Request, res: Response): Promise registeredAt: storedQuote.registeredAt, } - if (swapServiceStatus?.buyTxHash) { - response.buyTxHash = swapServiceStatus.buyTxHash + if (txHash && !storedQuote.txHash) { + const registeredQuote = { + ...storedQuote, + txHash, + registeredAt: Date.now(), + status: 'submitted' as const, + } + + quoteStore.set(quoteId, registeredQuote) + await registerSwapInService(registeredQuote) + + response.txHash = registeredQuote.txHash + response.registeredAt = registeredQuote.registeredAt + response.status = registeredQuote.status + + res.json(response) + return + } + + const swapResponse = await fetchSwapService( + res, + `${env.SWAP_SERVICE_BASE_URL}/swaps/${quoteId}`, + undefined, + STATUS_TIMEOUT_MS, + ) + + if (!swapResponse) return + + if (swapResponse.ok) { + const statusResult = SwapServiceStatusSchema.safeParse( + await swapResponse.json().catch(() => null), + ) + + if (!statusResult.success) { + console.error( + 'Unexpected response shape from swap-service /swaps/:quoteId:', + statusResult.error.errors, + ) + res.status(503).json({ + error: 'Invalid response from swap service', + code: 'INVALID_RESPONSE', + } satisfies ErrorResponse) + return + } + + const swapServiceStatus = statusResult.data + + const status = + swapServiceStatus.status === 'SUCCESS' + ? 'confirmed' + : swapServiceStatus.status === 'FAILED' + ? 'failed' + : storedQuote.status + + if (status !== storedQuote.status && (status === 'confirmed' || status === 'failed')) { + response.status = status + quoteStore.set(quoteId, { ...storedQuote, status }) + } + + if (swapServiceStatus.buyTxHash) response.buyTxHash = swapServiceStatus.buyTxHash + if (swapServiceStatus.isAffiliateVerified !== undefined) { + response.isAffiliateVerified = swapServiceStatus.isAffiliateVerified + } + + res.json(response) + return } - if (swapServiceStatus?.isAffiliateVerified !== undefined) { - response.isAffiliateVerified = swapServiceStatus.isAffiliateVerified + + if (swapResponse.status === 404) { + await registerSwapInService(storedQuote) } res.json(response) } catch (error) { console.error('Error in getSwapStatus:', error) - res.status(500).json({ error: 'Internal server error' } as ErrorResponse) + res.status(500).json({ error: 'Internal server error' } satisfies ErrorResponse) } } diff --git a/packages/public-api/src/routes/status/types.ts b/packages/public-api/src/routes/status/types.ts index 2d8c215c769..1d4a4d9c93e 100644 --- a/packages/public-api/src/routes/status/types.ts +++ b/packages/public-api/src/routes/status/types.ts @@ -1,19 +1,24 @@ import { z } from 'zod' import { registry } from '../../registry' +import { EVM_ADDRESS } from '../../types' -export type SwapServiceStatus = { - status: 'IDLE' | 'PENDING' | 'SUCCESS' | 'FAILED' - sellTxHash?: string - buyTxHash?: string - statusMessage: string - isAffiliateVerified?: boolean - affiliateVerificationDetails?: { - hasAffiliate: boolean - affiliateBps?: number - affiliateAddress?: string - } -} +export const SwapServiceStatusSchema = z.object({ + status: z.enum(['IDLE', 'PENDING', 'SUCCESS', 'FAILED']), + sellTxHash: z.string().optional(), + buyTxHash: z.string().optional(), + statusMessage: z.string(), + isAffiliateVerified: z.boolean().optional(), + affiliateVerificationDetails: z + .object({ + hasAffiliate: z.boolean(), + affiliateBps: z.number().optional(), + affiliateAddress: EVM_ADDRESS.optional(), + }) + .optional(), +}) + +export type SwapServiceStatus = z.infer export const StatusRequestSchema = z.object({ quoteId: z.string().uuid(), @@ -31,7 +36,7 @@ export const SwapStatusResponseSchema = registry.register( buyAssetId: z.string(), sellAmountCryptoBaseUnit: z.string(), buyAmountAfterFeesCryptoBaseUnit: z.string(), - affiliateAddress: z.string().optional(), + affiliateAddress: EVM_ADDRESS.optional(), affiliateBps: z.string(), registeredAt: z.number().optional(), buyTxHash: z.string().optional(), diff --git a/packages/public-api/src/types.ts b/packages/public-api/src/types.ts index 341542fc3bd..3b2f90219d3 100644 --- a/packages/public-api/src/types.ts +++ b/packages/public-api/src/types.ts @@ -77,6 +77,11 @@ export const rateLimitResponse = { }, } +export const EVM_ADDRESS = z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/, 'must be a valid EVM address') + .openapi({ example: '0x1234567890123456789012345678901234567890' }) + export const PartnerCodeHeaderSchema = z.object({ 'X-Partner-Code': z .string()