diff --git a/.env.sample b/.env.sample index ef5ce10..10726fa 100644 --- a/.env.sample +++ b/.env.sample @@ -32,21 +32,4 @@ HYPEREVM_RPC_URL= MONAD_RPC_URL= FOGO_RPC_URL= -# API Key Authentication (disabled by default) -# Set to "true" to require API keys for /build and /permit-params endpoints -# Note: /quote endpoint is exempt from rate limiting but still tracked in metrics -ENABLE_API_KEY= - -# Comma-separated list of valid API keys (only used when ENABLE_API_KEY=true) -API_KEYS= - -# Rate limiting configuration -# Time window in milliseconds (default: 60000 = 1 minute) -RATE_LIMIT_WINDOW_MS= -# Maximum requests per window per API key (default: 100) -RATE_LIMIT_MAX_REQUESTS= - -# For e2e tests -API_KEY= - EXPECTED_SIGNER_ADDRESS=0xe8FDd6f6D10532bd49Cced5502CAa483E232E637 \ No newline at end of file diff --git a/README.md b/README.md index 0a7b3d7..18865c4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ A RESTful API service that generates unsigned transactions for cross-chain swaps - **Permit Support**: EIP-2612 permit signatures for gasless token approvals - **Monochain Swaps**: Single-chain token swaps with DEX aggregation - **Quote Signature Verification**: Cryptographic verification of all quotes -- **API Key Authentication**: Optional API key requirement with rate limiting - **Prometheus Metrics**: Built-in metrics endpoint for monitoring ## Supported Chains @@ -67,10 +66,6 @@ cp .env.example .env | `AVALANCHE_RPC_URL` | Avalanche RPC endpoint | Public RPC | | `BSC_RPC_URL` | BSC RPC endpoint | Public RPC | | `OPTIMISM_RPC_URL` | Optimism RPC endpoint | Public RPC | -| `ENABLE_API_KEY` | Enable API key authentication | `false` | -| `API_KEYS` | Comma-separated list of valid API keys | - | -| `RATE_LIMIT_WINDOW_MS` | Rate limit time window in ms | `60000` | -| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window per API key | `100` | ## Running the Server @@ -138,63 +133,6 @@ Then run: docker compose up -d ``` -## API Key Authentication - -The service supports optional API key authentication with per-key rate limiting. By default, authentication is disabled. - -### Enabling API Key Authentication - -1. Set `ENABLE_API_KEY=true` in your environment -2. Configure valid API keys as a comma-separated list in `API_KEYS` -3. Optionally configure rate limits with `RATE_LIMIT_WINDOW_MS` and `RATE_LIMIT_MAX_REQUESTS` - -### Using API Keys - -When authentication is enabled, include the `X-API-Key` header in your requests: - -```bash -curl -X POST http://localhost:3000/build \ - -H "Content-Type: application/json" \ - -H "X-API-Key: your-api-key" \ - -d '{ ... }' -``` - -### Rate Limiting - -- Rate limiting applies to `/build`, `/permit-params`, and `/hypercore/permit-params` endpoints -- The `/quote` endpoint is exempt from rate limiting (but still tracked in metrics) -- `/health` and `/metrics` endpoints bypass authentication entirely -- Default: 100 requests per minute per API key - -### Error Responses - -**Missing API Key (when authentication is enabled):** -```json -{ - "success": false, - "error": "API key required. Please provide X-API-Key header.", - "code": "UNAUTHORIZED" -} -``` - -**Invalid API Key:** -```json -{ - "success": false, - "error": "Invalid API key", - "code": "UNAUTHORIZED" -} -``` - -**Rate Limit Exceeded:** -```json -{ - "success": false, - "error": "Rate limit exceeded. Please try again later.", - "code": "RATE_LIMITED" -} -``` - ## API Endpoints ### Health Check @@ -249,9 +187,8 @@ GET /metrics Returns Prometheus-formatted metrics for monitoring. This endpoint is always accessible without authentication. **Available Metrics:** -- `api_requests_total` - Total API requests by API key, endpoint, method, and status +- `api_requests_total` - Total API requests by endpoint, method, and status - `api_request_duration_seconds` - Request duration histogram -- `rate_limit_exceeded_total` - Rate limit exceeded events by API key and endpoint - Default Node.js metrics (CPU, memory, event loop, etc.) **Example Prometheus scrape config:** @@ -788,8 +725,6 @@ All endpoints return consistent error responses: | `INVALID_SIGNATURE` | Quote signature verification failed | | `BUILD_FAILED` | Transaction building failed | | `INTERNAL_ERROR` | Unexpected server error | -| `UNAUTHORIZED` | Missing or invalid API key | -| `RATE_LIMITED` | Rate limit exceeded | ## Architecture @@ -804,7 +739,7 @@ src/ │ ├── svm.ts # Solana transaction builder │ └── sui.ts # Sui transaction builder ├── middleware/ -│ └── apiKey.ts # API key auth and rate limiting +│ └── apiKey.ts # Request metrics tracking └── utils/ ├── signature.ts # Quote signature verification └── hypercore.ts # HyperCore permit utilities diff --git a/bun.lock b/bun.lock index ebe6fb3..e01d116 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "tx-builder", "dependencies": { - "@mayanfinance/swap-sdk": "^12.2.2", + "@mayanfinance/swap-sdk": "^13.2.0", "@mysten/sui": "^1.17.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.0", @@ -94,7 +94,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@mayanfinance/swap-sdk": ["@mayanfinance/swap-sdk@12.2.2", "", { "dependencies": { "@mysten/sui": "^1.34.0", "@noble/hashes": "1.8.0", "@solana/buffer-layout": "^4 || ^3", "@solana/web3.js": "^1.87.6", "bs58": "^6.0.0", "cross-fetch": "^3.1.5", "ethers": "^6", "js-sha3": "^0.8.0" } }, "sha512-2tOdoJfl6zpW6nSl9vVGQVykPLWGqk+pA4sUWhwITTvWaj5dAzDTyRQ2515QLfWHRo6JR9g82brGSgBTphZLTw=="], + "@mayanfinance/swap-sdk": ["@mayanfinance/swap-sdk@13.2.0", "", { "dependencies": { "@mysten/sui": "^1.34.0", "@noble/hashes": "1.8.0", "@solana/buffer-layout": "^4 || ^3", "@solana/web3.js": "^1.87.6", "bs58": "^6.0.0", "cross-fetch": "^3.1.5", "ethers": "^6", "js-sha3": "^0.8.0" } }, "sha512-4ugOSZOHjwXZhkyIsDVd9y764BvwHl72+yDTrkNXWlyHnkZTbJUd1U/KyFeA0LpUcAD9ocdsvAP8jFJcLWXSPw=="], "@mysten/bcs": ["@mysten/bcs@1.9.2", "", { "dependencies": { "@mysten/utils": "0.2.0", "@scure/base": "^1.2.6" } }, "sha512-kBk5xrxV9OWR7i+JhL/plQrgQ2/KJhB2pB5gj+w6GXhbMQwS3DPpOvi/zN0Tj84jwPvHMllpEl0QHj6ywN7/eQ=="], diff --git a/package.json b/package.json index e46ee05..c508ade 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "typescript": "^5.0.0" }, "dependencies": { - "@mayanfinance/swap-sdk": "^12.2.2", + "@mayanfinance/swap-sdk": "^13.2.0", "@mysten/sui": "^1.17.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.0", diff --git a/src/builders/evm.ts b/src/builders/evm.ts index 0ab101b..803c1d7 100644 --- a/src/builders/evm.ts +++ b/src/builders/evm.ts @@ -61,6 +61,10 @@ export async function buildEvmTransaction( } // Non-gasless: Build transaction calldata + const options = { + usdcPermitSignature, + apiKey: process.env.SWAP_SDK_API_KEY, + } const txPayload = await getSwapFromEvmTxPayload( quote, swapperAddress, @@ -70,7 +74,7 @@ export async function buildEvmTransaction( signerChainId, payload, permitParam, - usdcPermitSignature ? { usdcPermitSignature } : undefined + options, ); // Resolve the 'to' address if it's a promise diff --git a/src/builders/sui.ts b/src/builders/sui.ts index 2957f6d..b9d6a69 100644 --- a/src/builders/sui.ts +++ b/src/builders/sui.ts @@ -46,7 +46,9 @@ export async function buildSuiTransaction( const payload = customPayload ? hexToBuffer(customPayload) : undefined; // Build composable options - const options: ComposableSuiMoveCallsOptions = {}; + const options: ComposableSuiMoveCallsOptions = { + apiKey: process.env.SWAP_SDK_API_KEY, + }; if (usdcPermitSignature) { options.usdcPermitSignature = usdcPermitSignature; diff --git a/src/builders/svm.ts b/src/builders/svm.ts index 47ac2a2..301d981 100644 --- a/src/builders/svm.ts +++ b/src/builders/svm.ts @@ -50,6 +50,7 @@ export async function buildSvmTransaction( forceSkipCctpInstructions, separateSwapTx, skipProxyMayanInstructions, + apiKey: process.env.SWAP_SDK_API_KEY, } ); diff --git a/src/middleware/apiKey.ts b/src/middleware/apiKey.ts index efd9362..72925f1 100644 --- a/src/middleware/apiKey.ts +++ b/src/middleware/apiKey.ts @@ -7,11 +7,11 @@ export const metricsRegistry = new Registry(); // Collect default metrics (CPU, memory, etc.) collectDefaultMetrics({ register: metricsRegistry }); -// API request counter per API key and endpoint +// API request counter per endpoint export const apiRequestsTotal = new Counter({ name: 'api_requests_total', help: 'Total number of API requests', - labelNames: ['api_key', 'endpoint', 'method', 'status'], + labelNames: ['endpoint', 'method', 'status'], registers: [metricsRegistry], }); @@ -19,177 +19,30 @@ export const apiRequestsTotal = new Counter({ export const apiRequestDuration = new Histogram({ name: 'api_request_duration_seconds', help: 'Duration of API requests in seconds', - labelNames: ['api_key', 'endpoint', 'method'], + labelNames: ['endpoint', 'method'], buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], registers: [metricsRegistry], }); -// Rate limit exceeded counter -export const rateLimitExceeded = new Counter({ - name: 'rate_limit_exceeded_total', - help: 'Total number of rate limit exceeded events', - labelNames: ['api_key', 'endpoint'], - registers: [metricsRegistry], -}); - -// In-memory rate limiting store -interface RateLimitEntry { - count: number; - windowStart: number; -} - -const rateLimitStore = new Map(); - -// Rate limit configuration per API key -export interface RateLimitConfig { - windowMs: number; // Time window in milliseconds - maxRequests: number; // Maximum requests per window -} - -export interface ApiKeyConfig { - apiKeys: Set; - enabled: boolean; - rateLimit: RateLimitConfig; -} - -// Default configuration -const defaultRateLimit: RateLimitConfig = { - windowMs: 60000, // 1 minute - maxRequests: 100, // 100 requests per minute -}; - -/** - * Parse API keys from environment variable - * Format: comma-separated list of API keys - */ -export function parseApiKeys(envValue: string | undefined): Set { - if (!envValue) return new Set(); - return new Set( - envValue - .split(',') - .map((key) => key.trim()) - .filter((key) => key.length > 0) - ); -} - -/** - * Get API key configuration from environment - */ -export function getApiKeyConfig(): ApiKeyConfig { - const enabled = process.env.ENABLE_API_KEY === 'true'; - const apiKeys = parseApiKeys(process.env.API_KEYS); - - const rateLimit: RateLimitConfig = { - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || String(defaultRateLimit.windowMs), 10), - maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || String(defaultRateLimit.maxRequests), 10), - }; - - return { apiKeys, enabled, rateLimit }; -} - -/** - * Check if request is within rate limit - */ -function checkRateLimit(apiKey: string, config: RateLimitConfig): boolean { - const now = Date.now(); - const key = `${apiKey}`; - const entry = rateLimitStore.get(key); - - if (!entry || now - entry.windowStart > config.windowMs) { - // Start new window - rateLimitStore.set(key, { count: 1, windowStart: now }); - return true; - } - - if (entry.count >= config.maxRequests) { - return false; - } - - entry.count++; - return true; -} - /** - * Clean up expired rate limit entries periodically + * Express middleware for request metrics tracking */ -export function startRateLimitCleanup(intervalMs: number = 60000): NodeJS.Timeout { - return setInterval(() => { - const now = Date.now(); - const config = getApiKeyConfig(); - - for (const [key, entry] of rateLimitStore.entries()) { - if (now - entry.windowStart > config.rateLimit.windowMs) { - rateLimitStore.delete(key); - } - } - }, intervalMs); -} - -// Endpoints that are exempt from rate limiting (but still require API key if enabled) -const RATE_LIMIT_EXEMPT_ENDPOINTS = ['/quote']; - -/** - * Express middleware for API key authentication and rate limiting - */ -export function apiKeyMiddleware(config?: ApiKeyConfig) { - const finalConfig = config || getApiKeyConfig(); - +export function metricsMiddleware() { return (req: Request, res: Response, next: NextFunction) => { - // Skip middleware entirely for health check, metrics, and OPTIONS preflight requests if (req.path === '/health' || req.path === '/metrics' || req.method === 'OPTIONS') { return next(); } const startTime = Date.now(); - const apiKey = req.header('X-API-Key') || 'anonymous'; const endpoint = req.path; const method = req.method; - // Track request duration on response finish res.on('finish', () => { const duration = (Date.now() - startTime) / 1000; - apiRequestDuration.labels(apiKey, endpoint, method).observe(duration); - apiRequestsTotal.labels(apiKey, endpoint, method, String(res.statusCode)).inc(); + apiRequestDuration.labels(endpoint, method).observe(duration); + apiRequestsTotal.labels(endpoint, method, String(res.statusCode)).inc(); }); - // If API key auth is disabled, allow all requests - if (!finalConfig.enabled) { - return next(); - } - - // Validate API key - if (!req.header('X-API-Key')) { - return res.status(401).json({ - success: false, - error: 'API key required. Please provide X-API-Key header.', - code: 'UNAUTHORIZED', - }); - } - - if (!finalConfig.apiKeys.has(apiKey)) { - return res.status(401).json({ - success: false, - error: 'Invalid API key', - code: 'UNAUTHORIZED', - }); - } - - // Skip rate limiting for exempt endpoints (e.g., /quote) - if (RATE_LIMIT_EXEMPT_ENDPOINTS.includes(req.path)) { - return next(); - } - - // Check rate limit - if (!checkRateLimit(apiKey, finalConfig.rateLimit)) { - rateLimitExceeded.labels(apiKey, endpoint).inc(); - - return res.status(429).json({ - success: false, - error: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMITED', - }); - } - next(); }; } @@ -200,26 +53,3 @@ export function apiKeyMiddleware(config?: ApiKeyConfig) { export async function getMetrics(): Promise { return metricsRegistry.metrics(); } - -/** - * Get current rate limit status for an API key - */ -export function getRateLimitStatus(apiKey: string, config: RateLimitConfig): { - remaining: number; - resetAt: number; -} { - const entry = rateLimitStore.get(apiKey); - const now = Date.now(); - - if (!entry || now - entry.windowStart > config.windowMs) { - return { - remaining: config.maxRequests, - resetAt: now + config.windowMs, - }; - } - - return { - remaining: Math.max(0, config.maxRequests - entry.count), - resetAt: entry.windowStart + config.windowMs, - }; -} diff --git a/src/server.ts b/src/server.ts index 86804e9..77cdd64 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import {fetchQuote, type Quote, type QuoteParams, type QuoteOptions, addresses} import { verifyQuoteSignature } from './utils/signature'; import { buildTransaction, type BuilderConnections } from './builders'; import { getPermitParams, getHyperCorePermitParams } from './utils/hypercore'; -import { apiKeyMiddleware, getMetrics, startRateLimitCleanup, getApiKeyConfig } from './middleware/apiKey'; +import { metricsMiddleware, getMetrics } from './middleware/apiKey'; import type { SignedQuote, BuildTransactionRequest, @@ -72,10 +72,8 @@ export function createServer(config: ServerConfig) { next(); }); - // Apply API key middleware for authentication and rate limiting - // Note: /quote endpoint is exempt from rate limiting, but still tracked in metrics - const apiKeyConfig = getApiKeyConfig(); - app.use(apiKeyMiddleware(apiKeyConfig)); + // Apply metrics middleware + app.use(metricsMiddleware()); // Initialize connections const connections: BuilderConnections = { @@ -167,7 +165,9 @@ export function createServer(config: ServerConfig) { if (body.referrerBps !== undefined) quoteParams.referrerBps = body.referrerBps; // Build QuoteOptions from flat request - const quoteOptions: QuoteOptions = {}; + const quoteOptions: QuoteOptions = { + apiKey: process.env.SWAP_SDK_API_KEY, + }; if (body.wormhole !== undefined) quoteOptions.wormhole = body.wormhole; if (body.swift !== undefined) quoteOptions.swift = body.swift; if (body.mctp !== undefined) quoteOptions.mctp = body.mctp; @@ -191,10 +191,11 @@ export function createServer(config: ServerConfig) { // Handle SDK errors which have { code, message, data? } format const sdkError = error as { code?: string | number; message?: string; msg?: string; data?: unknown }; - if (sdkError.code !== undefined && (sdkError.message || sdkError.msg)) { + const sdkErrorMessage = sdkError.message || sdkError.msg || 'Unknown error'; + if (sdkError.code !== undefined) { return res.status(400).json({ success: false, - error: sdkError.message || sdkError.msg, + error: sdkErrorMessage, code: String(sdkError.code), ...(sdkError.data !== undefined && { data: sdkError.data }), }); @@ -530,20 +531,12 @@ export function startServer(config: ServerConfig) { const app = createServer(config); const metricsApp = createMetricsServer(); - // Start rate limit cleanup interval - const cleanupInterval = startRateLimitCleanup(); - // Start main API server const server = app.listen(config.port, () => { - const apiKeyConfig = getApiKeyConfig(); console.log(`Mayan TX Builder API running on port ${config.port}`); console.log(` Health check: http://localhost:${config.port}/health`); console.log(` Quote endpoint: GET http://localhost:${config.port}/quote`); console.log(` Build endpoint: POST http://localhost:${config.port}/build`); - console.log(` API Key auth: ${apiKeyConfig.enabled ? 'enabled' : 'disabled'}`); - if (apiKeyConfig.enabled) { - console.log(` Rate limit: ${apiKeyConfig.rateLimit.maxRequests} requests per ${apiKeyConfig.rateLimit.windowMs / 1000}s`); - } }); // Start metrics server on separate port @@ -554,7 +547,6 @@ export function startServer(config: ServerConfig) { // Cleanup on server close server.on('close', () => { - clearInterval(cleanupInterval); metricsServer.close(); }); diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 29b4ffd..9239c0e 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -90,7 +90,7 @@ describe('E2E: Sui -> Solana', () => { const fromChain = 'sui'; const toChain = 'solana'; const slippage: 'auto' | number = 'auto'; - const amountIn64 = '1000000000'; // 1 SUI (9 decimals) + const amountIn64 = '2000000000'; // 1 SUI (9 decimals) // =========================================== if (process.env.EXECUTE === 'true') { diff --git a/tests/utils.ts b/tests/utils.ts index 31a89b9..aabb4af 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -192,7 +192,6 @@ export async function fetchQuote(params: QuoteParams): Promise { const response = await fetch(`${SERVER_URL}/quote?${queryParams}`, { method: 'GET', - headers: { 'X-API-KEY': process.env.API_KEY || '' }, }); const data = await response.json(); @@ -219,7 +218,6 @@ export async function fetchQuoteRaw(params: QuoteParams): Promise<{ status: numb const response = await fetch(`${SERVER_URL}/quote?${queryParams}`, { method: 'GET', - headers: { 'X-API-KEY': process.env.API_KEY || '' }, }); const data = await response.json(); @@ -230,7 +228,7 @@ export async function fetchQuoteRaw(params: QuoteParams): Promise<{ status: numb export async function buildTransaction(quote: any, params: any): Promise { const response = await fetch(`${SERVER_URL}/build`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.API_KEY || '' }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote, params }), }); @@ -331,7 +329,7 @@ export async function executeEvmTransaction(result: any, chain: string): Promise export async function fetchPermitParams(quote: any, walletAddress: string, deadline?: string): Promise { const response = await fetch(`${SERVER_URL}/permit-params`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.API_KEY || '' }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote, walletAddress,