-
Notifications
You must be signed in to change notification settings - Fork 44
feat: router api #1481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: router api #1481
Changes from all commits
6965d5b
2c4a8ea
09d9409
1800926
0ede4a7
7519a93
6ead6d9
bed2302
7dd6f25
f0ffefc
7b72827
f4dd348
3349218
1219041
bca40b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
}), | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the meaning behind the underscore convention here? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} |
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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
@@ -21,9 +19,10 @@ | |
}, | ||
"devDependencies": { | ||
"@types/node": "24.5.2", | ||
"dotenv": "^17.2.3", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
} | ||
|
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't need to |
||
|
||
const instances: { [P in ChainId]?: Promise<CurveJS> } = {} | ||
|
||
const FACTORIES = [ | ||
'factory', | ||
'cryptoFactory', | ||
'twocryptoFactory', | ||
'crvUSDFactory', | ||
'tricryptoFactory', | ||
'stableNgFactory', | ||
] as const | ||
|
||
async function hydrateCurve(curve: CurveJS, log: FastifyBaseLogger) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I hate how we now have hydration in the server too 🥲 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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())) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm confused, are you now calling There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Variable |
||
return curve | ||
})() | ||
} | ||
return instances[chainId]! | ||
} |
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[] | ||
} |
Uh oh!
There was an error while loading. Please reload this page.