Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ coverage
cypress.env.json
tests/cypress/screenshots

# next.js
.next/
out/
build
# Vercel
/apps/main/.vercel/

# misc
.DS_Store
Expand Down
22 changes: 22 additions & 0 deletions apps/main/_api/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type IncomingMessage, ServerResponse } from 'http'
import { buildServer } from 'router-api/src/server'

const server = buildServer()

/**
* Vercel handler for API routes, using fastify under the hood. This is only used when deployed to Vercel.
* While in development, vite's dev server redirects /api/* calls to the router-api dev server.
*/
export default async function handler(request: IncomingMessage, response: ServerResponse) {
const start = Date.now()
await server.ready()
server.server.emit('request', request, response)
response.on('finish', () =>
server.log.info({
message: 'request finished',
method: request.method,
path: request.url,
runtimeMs: Date.now() - start,
}),
)
}
2 changes: 1 addition & 1 deletion apps/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@types/node": "24.3.0",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "^5.0.1",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-styled-components": "^2.1.4",
"eslint": "*",
"eslint-config-custom": "*",
Expand Down
2 changes: 1 addition & 1 deletion apps/main/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"@curvefi/prices-api/*": ["../../packages/prices-api/src/*"]
}
},
"include": ["src", "../../packages/ui/src/globals.d.ts"]
"include": ["src", "_api", "../../packages/ui/src/globals.d.ts"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the meaning behind the underscore convention here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's where the vite vercel plugin looks for API endpoints. But I think it can be just api too

}
35 changes: 11 additions & 24 deletions apps/main/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ import react from '@vitejs/plugin-react'
import svgr from 'vite-plugin-svgr'
import vercel from 'vite-plugin-vercel'

const { API_PROXY_TARGET = 'http://localhost:3010' } = process.env

// https://vite.dev/config/
export default defineConfig(({ command }) => ({
server: {
port: 3000,
hmr: true,
},
export default defineConfig(({ command, mode }) => ({
// the local server starts on port 3000 by default, with hot module reload enabled and /api proxying
server: { port: 3000, hmr: true, proxy: { '/api': { target: API_PROXY_TARGET, changeOrigin: true } } },
preview: { port: 3000 },
plugins: [react(), svgr(), vercel()],
optimizeDeps: {
include: ['styled-components', '@mui/material', '@mui/icons-material'],
},
optimizeDeps: { include: ['styled-components', '@mui/material', '@mui/icons-material'] },
resolve: {
alias: [
{ find: '@', replacement: resolve(__dirname, './src') },
Expand All @@ -24,25 +22,14 @@ export default defineConfig(({ command }) => ({
{ find: '@curvefi/prices-api', replacement: resolve(__dirname, '../../packages/prices-api/src') },
],
},
define: {
'process.env.NODE_ENV': command === 'serve' ? '"development"' : '"production"',
},
define: { 'process.env.NODE_ENV': command === 'serve' ? '"development"' : '"production"' },
vercel: {
buildCommand: 'yarn build',
rewrites: [
{
source: '/favicon',
destination: '/favicon.ico',
},
{
source: '/security.txt',
destination: '/.well-known/security.txt',
statusCode: 308, // Permanent redirect
},
{
source: '/(.*)',
destination: '/index.html',
},
{ source: '/favicon', destination: '/favicon.ico' },
{ source: '/api/(.*)', destination: '/api/router' },
{ source: '/security.txt', destination: '/.well-known/security.txt', statusCode: 308 /* Permanent redirect */ },
{ source: '/(.*)', destination: '/index.html' },
],
},
}))
40 changes: 40 additions & 0 deletions apps/router-api/.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Router API service environment configuration
# Copy to .env.local or export variables in your runtime environment.
# Vitest E2E suites load this file automatically; ensure keys below point to
# test-safe infrastructure before running `yarn workspace router-api test:e2e`.

# Optional server overrides
# PORT controls the listening port for the Fastify server.
Expand All @@ -8,3 +10,41 @@ PORT=3010
HOST=0.0.0.0
# SERVICE_NAME participates in /health responses for observability.
SERVICE_NAME=router-api

# RPC endpoints are configured dynamically based on Curve network identifiers.
# Build the environment variable name as RPC_URL_${sanitizeId(id)} where sanitizeId uppercases and replaces non-alphanumeric characters with underscores.
# Reference the supported network list in apps/router-api/src/networks.ts when provisioning keys.
RPC_URL_ETHEREUM=https://ethereum.publicnode.com
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these configured by env variable? Doesn't look very scalable, and if you need to add a new chain you have to restart the API server anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In prod we can decide to use a paid rpc for example

RPC_URL_OPTIMISM=https://optimism.drpc.org
RPC_URL_XDC=https://rpc1.xinfin.network
RPC_URL_BSC=https://bsc-dataseed1.binance.org/
RPC_URL_BSC_TESTNET=https://data-seed-prebsc-1-s1.binance.org:8545/
RPC_URL_XDAI=https://rpc.gnosischain.com
RPC_URL_POLYGON=https://polygon-rpc.com
RPC_URL_SONIC=https://rpc.soniclabs.com
RPC_URL_X_LAYER=https://rpc.xlayer.tech
RPC_URL_TAC=https://rpc.ankr.com/tac
RPC_URL_FANTOM=https://rpc.ftm.tools/
RPC_URL_FRAXTAL=https://rpc.frax.com
RPC_URL_ZKSYNC=https://mainnet.era.zksync.io
RPC_URL_HYPERLIQUID=https://rpc.hyperliquid.xyz/evm
RPC_URL_MOONBEAM=https://moonbeam.public.blastapi.io
RPC_URL_KAVA=https://evm.kava.io
RPC_URL_TAC_TESTNET=https://turin.rpc.tac.build
RPC_URL_MANTLE=https://rpc.mantle.xyz
RPC_URL_MEGAETH=https://carrot.megaeth.com/rpc
RPC_URL_BASE=https://base.drpc.org
RPC_URL_PLASMA=https://rpc.plasma.to
RPC_URL_MONAD=https://rpc.ankr.com/monad_testnet
RPC_URL_EXPCHAIN=https://rpc0-testnet.expchain.ai
RPC_URL_ARBITRUM=https://arb1.arbitrum.io/rpc
RPC_URL_CELO=https://forno.celo.org
RPC_URL_ETHERLINK=https://node.mainnet.etherlink.com/
RPC_URL_AVALANCHE=https://api.avax.network/ext/bc/C/rpc
RPC_URL_INK=https://rpc-gel.inkonchain.com
RPC_URL_PLUME=https://rpc.plume.org
RPC_URL_TAIKO=https://rpc.ankr.com/taiko
RPC_URL_ARBITRUM_SEPOLIA=https://sepolia-rollup.arbitrum.io/rpc
RPC_URL_CORN=https://rpc.ankr.com/corn_maizenet
RPC_URL_NEON=https://devnet.neonevm.org
RPC_URL_AURORA=https://mainnet.aurora.dev
7 changes: 3 additions & 4 deletions apps/router-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
"node": "22"
},
"scripts": {
"dev": "tsx watch src/local.ts",
"start": "node dist/local.js",
"build": "tsc --project tsconfig.build.json",
"dev": "tsx watch src/dev.ts",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "vitest run",
Expand All @@ -21,9 +19,10 @@
},
"devDependencies": {
"@types/node": "24.5.2",
"dotenv": "^17.2.3",
Copy link
Collaborator

@0xAlunara 0xAlunara Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modern version of Node and other javascript runtimes usually have support for env files now. I don't think we need this package any longer?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah cool I didn't know that

"eslint": "*",
"eslint-config-custom": "*",
"tsx": "^4.20.6",
"tsx": "4.20.6",
"typescript": "*",
"vitest": "^3.2.4"
}
Expand Down
46 changes: 46 additions & 0 deletions apps/router-api/src/curvejs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FastifyBaseLogger } from 'fastify/types/logger'
import { type default as curveApi, createCurve } from '@curvefi/api'
import { resolveRpc } from './rpc/network-metadata'

export type CurveJS = typeof curveApi
export type ChainId = number
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need to export


const instances: { [P in ChainId]?: Promise<CurveJS> } = {}

const FACTORIES = [
'factory',
'cryptoFactory',
'twocryptoFactory',
'crvUSDFactory',
'tricryptoFactory',
'stableNgFactory',
] as const

async function hydrateCurve(curve: CurveJS, log: FastifyBaseLogger) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hate how we now have hydration in the server too 🥲

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For routes it kind of makes sense

const factories = FACTORIES.map((key) => curve[key])
await Promise.all(
factories.map(async (factory) => {
await factory.fetchPools()
if ('fetchNewPools' in factory) await factory.fetchNewPools()
}),
)
log.info({ message: 'curve client initialized', chainId: curve.chainId })
return () => Promise.all(factories.map((factory) => 'fetchNewPools' in factory && factory.fetchNewPools()))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused, are you now calling fetchNewPools() twice for factories?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not being called rn. We need to add some refresh mechanism but vercel kills the pods pretty fast

}

/**
* Get a Curve.js instance for a specific chain ID, initializing it if necessary.
* The instance is cached for future use. Also returns a function to refresh the pool data.
*/
export const loadCurve = (chainId: number, log: FastifyBaseLogger) => {
if (!instances[chainId]) {
instances[chainId] = (async () => {
const curve = createCurve()
const { url } = await resolveRpc(chainId, curve)
await curve.init('JsonRpc', { url }, { chainId })
const refresh = await hydrateCurve(curve, log)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable refresh is not being used. Guess the behaviour is out of sync with the function docs.

return curve
})()
}
return instances[chainId]!
}
4 changes: 4 additions & 0 deletions apps/router-api/src/local.ts → apps/router-api/src/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import dotenv from 'dotenv'
import type { FastifyListenOptions } from 'fastify'
import { buildServer } from './server'

dotenv.config() // Load environment variables from .env file

const loadConfigFromEnv = ({ HOST, PORT = 3010 } = process.env): FastifyListenOptions => ({
port: Number(PORT),
host: HOST,
Expand All @@ -9,6 +12,7 @@ const loadConfigFromEnv = ({ HOST, PORT = 3010 } = process.env): FastifyListenOp
/**
* Starts the Fastify server and sets up graceful shutdown handlers.
* This function is invoked when running the application locally.
* On Vercel, we use the server instance directly in the API route handler of the main app.
*/
async function start(): Promise<void> {
const server = buildServer()
Expand Down
101 changes: 101 additions & 0 deletions apps/router-api/src/routes/optimal-route.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Address } from 'viem'
import type { IRouteStep } from '@curvefi/api/lib/interfaces'

export type Decimal = `${number}`

export const ADDRESS_HEX_PATTERN = '^0x[a-fA-F0-9]{40}$'

export const OptimalRoutePath = '/api/router/optimal-route'

const AddressSchema = { type: 'string', pattern: ADDRESS_HEX_PATTERN } as const
const AddressArraySchema = { type: 'array', items: AddressSchema, minItems: 1, maxItems: 1 } as const
const AmountArraySchema = { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 1 } as const

const optimalRouteQuerySchema = {
type: 'object',
required: ['tokenIn', 'tokenOut'],
additionalProperties: false,
properties: {
chainId: { type: 'integer', minimum: 1, default: 1 },
tokenIn: AddressArraySchema,
tokenOut: AddressArraySchema,
amountIn: AmountArraySchema,
amountOut: AmountArraySchema,
},
} as const

export type OptimalRouteQuery = {
chainId: number
tokenIn: [Address]
tokenOut: [Address]
amountIn?: [Decimal]
amountOut?: [Decimal]
}

const routeItemSchema = {
type: 'object',
required: ['amountOut', 'priceImpact', 'createdAt', 'route'],
properties: {
amountOut: { type: 'string' },
priceImpact: { anyOf: [{ type: 'number' }, { type: 'null' }] },
createdAt: { type: 'integer' },
route: {
type: 'array',
items: {
type: 'object',
required: ['tokenIn', 'tokenOut', 'protocol', 'action', 'args', 'chainId'],
properties: {
tokenIn: { type: 'array', items: { type: 'string' } },
tokenOut: { type: 'array', items: { type: 'string' } },
protocol: { type: 'string', enum: ['curve'] },
action: { type: 'string', enum: ['swap'] },
args: {
type: 'object',
required: ['poolId', 'swapAddress', 'swapParams', 'tvl'],
additionalProperties: true,
properties: {
poolId: { type: 'string' },
swapAddress: AddressSchema,
swapParams: {
description: 'Array of [inputCoinIndex, outputCoinIndex, swapType, amount, minAmountOut]',
type: 'array',
items: { type: 'integer' },
minItems: 5,
maxItems: 5,
},
poolAddress: AddressSchema,
basePool: AddressSchema,
baseToken: AddressSchema,
secondBasePool: AddressSchema,
secondBaseToken: AddressSchema,
tvl: { type: 'number' },
},
chainId: { type: 'integer' },
},
},
},
},
},
} as const

export const OptimalRouteSchema = {
querystring: optimalRouteQuerySchema,
response: { 200: { type: 'array', items: routeItemSchema } },
}

type RouteStep = {
tokenIn: [Address]
tokenOut: [Address]
protocol: 'curve'
action: 'swap'
args: Omit<IRouteStep, 'inputCoinAddress' | 'outputCoinAddress'>
chainId: number
}

export type RouteResponse = {
amountOut: string
priceImpact: number | null
createdAt: number
warnings: ('high-slippage' | 'low-exchange-rate')[]
route: RouteStep[]
}
Loading