@sip-protocol/api 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ import rateLimit, { type Store } from 'express-rate-limit'
2
+ import { RedisStore, type SendCommandFn } from 'rate-limit-redis'
3
+ import { Redis } from 'ioredis'
4
+ import type { Request, Response } from 'express'
5
+
6
+ /**
7
+ * Rate limiting configuration
8
+ *
9
+ * Environment variables:
10
+ * - RATE_LIMIT_WINDOW_MS: Window size in milliseconds (default: 60000 = 1 minute)
11
+ * - RATE_LIMIT_MAX_REQUESTS: Max requests per window (default: 100)
12
+ * - RATE_LIMIT_SKIP_FAILED: Skip failed requests from count (default: false)
13
+ * - RATE_LIMIT_STORE: Store type - 'redis' or 'memory' (default: 'memory')
14
+ * - REDIS_URL: Redis connection URL (required if store is 'redis')
15
+ *
16
+ * SECURITY NOTE: This rate limiter relies on Express trust proxy configuration
17
+ * to properly extract client IP from X-Forwarded-For header when behind a reverse proxy.
18
+ * Ensure TRUST_PROXY is correctly set in your environment configuration.
19
+ * See: https://expressjs.com/en/guide/behind-proxies.html
20
+ */
21
+
22
+ const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10)
23
+ const MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
24
+ const SKIP_FAILED = process.env.RATE_LIMIT_SKIP_FAILED === 'true'
25
+ const STORE_TYPE = process.env.RATE_LIMIT_STORE || 'memory'
26
+ const REDIS_URL = process.env.REDIS_URL
27
+
28
+ /**
29
+ * Redis client singleton for rate limiting
30
+ * Initialized lazily when Redis store is requested
31
+ */
32
+ let redisClient: Redis | null = null
33
+ let redisConnectionFailed = false
34
+
35
+ /**
36
+ * Get or create Redis client
37
+ * Returns null if Redis is not configured or connection failed
38
+ */
39
+ function getRedisClient(): Redis | null {
40
+ if (redisConnectionFailed) {
41
+ return null
42
+ }
43
+
44
+ if (!redisClient && REDIS_URL) {
45
+ redisClient = new Redis(REDIS_URL, {
46
+ maxRetriesPerRequest: 3,
47
+ retryStrategy: (times) => {
48
+ if (times > 3) {
49
+ console.warn('[rate-limit] Redis connection failed, falling back to memory store')
50
+ redisConnectionFailed = true
51
+ return null
52
+ }
53
+ return Math.min(times * 100, 1000)
54
+ },
55
+ lazyConnect: true,
56
+ })
57
+
58
+ redisClient.on('error', (err) => {
59
+ console.warn('[rate-limit] Redis error:', err.message)
60
+ })
61
+
62
+ redisClient.on('connect', () => {
63
+ console.log('[rate-limit] Redis connected successfully')
64
+ })
65
+ }
66
+
67
+ return redisClient
68
+ }
69
+
70
+ /**
71
+ * Check Redis connection health
72
+ */
73
+ export async function checkRedisHealth(): Promise<{
74
+ connected: boolean
75
+ latencyMs?: number
76
+ error?: string
77
+ }> {
78
+ const client = getRedisClient()
79
+ if (!client) {
80
+ return { connected: false, error: 'Redis not configured' }
81
+ }
82
+
83
+ try {
84
+ const start = Date.now()
85
+ await client.ping()
86
+ return { connected: true, latencyMs: Date.now() - start }
87
+ } catch (err) {
88
+ return {
89
+ connected: false,
90
+ error: err instanceof Error ? err.message : 'Unknown error',
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get Redis connection status for health endpoint
97
+ */
98
+ export function getRedisStatus(): {
99
+ storeType: 'redis' | 'memory'
100
+ configured: boolean
101
+ connected: boolean
102
+ } {
103
+ const client = getRedisClient()
104
+ return {
105
+ storeType: client && !redisConnectionFailed ? 'redis' : 'memory',
106
+ configured: !!REDIS_URL,
107
+ connected: client?.status === 'ready',
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Create rate limit store based on configuration
113
+ * Falls back to memory store if Redis is unavailable
114
+ */
115
+ function createStore(prefix: string): Store | undefined {
116
+ if (STORE_TYPE === 'redis' || REDIS_URL) {
117
+ const client = getRedisClient()
118
+ if (client && !redisConnectionFailed) {
119
+ const sendCommand: SendCommandFn = async (...args: string[]) => {
120
+ return client.call(args[0], ...args.slice(1)) as Promise<number | string>
121
+ }
122
+ return new RedisStore({
123
+ sendCommand,
124
+ prefix: `rl:${prefix}:`,
125
+ })
126
+ }
127
+ }
128
+
129
+ // Use default MemoryStore (undefined lets express-rate-limit use its default)
130
+ return undefined
131
+ }
132
+
133
+ // Key generator is intentionally not customized - we use express-rate-limit's
134
+ // default behavior which properly handles IPv6 addresses via req.ip
135
+ // See: https://express-rate-limit.github.io/ERR_ERL_KEY_GEN_IPV6/
136
+
137
+ /**
138
+ * Standard rate limiter for general API endpoints
139
+ */
140
+ export const rateLimiter = rateLimit({
141
+ windowMs: WINDOW_MS,
142
+ max: MAX_REQUESTS,
143
+ skipFailedRequests: SKIP_FAILED,
144
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
145
+ legacyHeaders: false, // Disable `X-RateLimit-*` headers
146
+ // Use Redis store if available, otherwise memory
147
+ store: createStore('api'),
148
+ handler: (_req: Request, res: Response) => {
149
+ res.status(429).json({
150
+ success: false,
151
+ error: {
152
+ code: 'RATE_LIMIT_EXCEEDED',
153
+ message: 'Too many requests, please try again later',
154
+ details: {
155
+ retryAfter: Math.ceil(WINDOW_MS / 1000),
156
+ limit: MAX_REQUESTS,
157
+ windowMs: WINDOW_MS,
158
+ },
159
+ },
160
+ })
161
+ },
162
+ skip: (req: Request) => {
163
+ // Skip rate limiting for health checks
164
+ return req.path === '/api/v1/health' || req.path === '/'
165
+ },
166
+ })
167
+
168
+ /**
169
+ * Strict rate limiter for sensitive endpoints (auth, proofs)
170
+ */
171
+ export const strictRateLimiter = rateLimit({
172
+ windowMs: 60000, // 1 minute
173
+ max: 10, // 10 requests per minute
174
+ skipFailedRequests: false,
175
+ standardHeaders: true,
176
+ legacyHeaders: false,
177
+ // Use Redis store if available with different prefix
178
+ store: createStore('strict'),
179
+ handler: (_req: Request, res: Response) => {
180
+ res.status(429).json({
181
+ success: false,
182
+ error: {
183
+ code: 'RATE_LIMIT_EXCEEDED',
184
+ message: 'Rate limit exceeded for sensitive endpoint',
185
+ details: {
186
+ retryAfter: 60,
187
+ limit: 10,
188
+ windowMs: 60000,
189
+ },
190
+ },
191
+ })
192
+ },
193
+ })
194
+
195
+ /**
196
+ * Graceful shutdown for Redis client
197
+ */
198
+ export async function shutdownRateLimiter(): Promise<void> {
199
+ if (redisClient) {
200
+ await redisClient.quit()
201
+ redisClient = null
202
+ }
203
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Request ID Middleware
3
+ *
4
+ * Generates unique request IDs for request correlation across services.
5
+ *
6
+ * Features:
7
+ * - Accepts client-provided X-Request-ID header for end-to-end tracing
8
+ * - Generates UUID v4 if no client ID provided
9
+ * - Sets X-Request-ID response header for client correlation
10
+ * - Attaches requestId to request object for use in routes/logging
11
+ *
12
+ * @example Client-side usage
13
+ * ```typescript
14
+ * // Send request with ID for tracing
15
+ * const response = await fetch('/api/v1/swap', {
16
+ * headers: { 'X-Request-ID': crypto.randomUUID() }
17
+ * })
18
+ *
19
+ * // Use response ID for error reporting
20
+ * const requestId = response.headers.get('X-Request-ID')
21
+ * if (!response.ok) {
22
+ * console.error(`Request ${requestId} failed`)
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ import { Request, Response, NextFunction, RequestHandler } from 'express'
28
+ import crypto from 'crypto'
29
+
30
+ /** Extended Request interface with requestId */
31
+ export interface RequestWithId extends Request {
32
+ requestId: string
33
+ }
34
+
35
+ /**
36
+ * Type guard to check if request has requestId
37
+ */
38
+ export function hasRequestId(req: Request): req is RequestWithId {
39
+ return 'requestId' in req && typeof (req as RequestWithId).requestId === 'string'
40
+ }
41
+
42
+ /**
43
+ * Request ID middleware
44
+ *
45
+ * Should be added early in the middleware chain (after shutdown middleware)
46
+ * so all subsequent middleware can access req.requestId.
47
+ */
48
+ export const requestIdMiddleware: RequestHandler = (
49
+ req: Request,
50
+ res: Response,
51
+ next: NextFunction
52
+ ): void => {
53
+ // Accept client-provided ID or generate new one
54
+ const clientId = req.headers['x-request-id']
55
+ const requestId = (typeof clientId === 'string' && clientId.length > 0)
56
+ ? clientId
57
+ : crypto.randomUUID()
58
+
59
+ // Attach to request for downstream use
60
+ ;(req as RequestWithId).requestId = requestId
61
+
62
+ // Set response header for client correlation
63
+ res.setHeader('X-Request-ID', requestId)
64
+
65
+ next()
66
+ }
@@ -1,13 +1,97 @@
1
1
  import { Request, Response, NextFunction } from 'express'
2
- import { z, ZodSchema } from 'zod'
2
+ import { z } from 'zod'
3
+ import type { ZodType } from 'zod'
4
+
5
+ /**
6
+ * Maximum value for uint256 (2^256 - 1)
7
+ */
8
+ export const MAX_UINT256 = 2n ** 256n - 1n
9
+
10
+ /**
11
+ * Schema for validating BigInt amount strings
12
+ *
13
+ * Validates:
14
+ * - Positive integers only (no zero, no negatives)
15
+ * - No leading zeros
16
+ * - Maximum 78 characters (max uint256 length)
17
+ * - Value <= 2^256 - 1
18
+ */
19
+ export const amountSchema = z
20
+ .string()
21
+ .regex(/^[1-9]\d*$/, 'Amount must be positive integer without leading zeros')
22
+ .max(78, 'Amount exceeds maximum uint256 length')
23
+ .refine(
24
+ (v) => {
25
+ try {
26
+ const n = BigInt(v)
27
+ return n > 0n && n <= MAX_UINT256
28
+ } catch {
29
+ return false
30
+ }
31
+ },
32
+ { message: 'Invalid amount: must be positive integer <= 2^256-1' }
33
+ )
34
+
35
+ /**
36
+ * Schema for validating minimum threshold amounts (allows zero)
37
+ *
38
+ * Used for:
39
+ * - minRequired in funding proofs (prove balance >= 0 is valid)
40
+ * - minAmount in swap outputs
41
+ *
42
+ * Validates:
43
+ * - Non-negative integers (zero allowed)
44
+ * - No leading zeros (except "0" itself)
45
+ * - Maximum 78 characters (max uint256 length)
46
+ * - Value <= 2^256 - 1
47
+ */
48
+ export const minAmountSchema = z
49
+ .string()
50
+ .regex(/^(0|[1-9]\d*)$/, 'Amount must be non-negative integer without leading zeros')
51
+ .max(78, 'Amount exceeds maximum uint256 length')
52
+ .refine(
53
+ (v) => {
54
+ try {
55
+ const n = BigInt(v)
56
+ return n >= 0n && n <= MAX_UINT256
57
+ } catch {
58
+ return false
59
+ }
60
+ },
61
+ { message: 'Invalid amount: must be non-negative integer <= 2^256-1' }
62
+ )
63
+
64
+ /**
65
+ * Safely calculate minimum amount with slippage
66
+ *
67
+ * @param input - Input amount as bigint
68
+ * @param slippageBps - Slippage in basis points (0-10000)
69
+ * @returns Minimum output amount after slippage
70
+ * @throws Error if slippage is out of range
71
+ */
72
+ export function calculateMinAmount(input: bigint, slippageBps: number): bigint {
73
+ if (slippageBps < 0 || slippageBps > 10000) {
74
+ throw new Error('Invalid slippage: must be 0-10000 basis points')
75
+ }
76
+ const bps = BigInt(Math.floor(slippageBps))
77
+ const multiplier = 10000n - bps
78
+ return (input * multiplier) / 10000n
79
+ }
80
+
81
+ /**
82
+ * Convert percentage (0-100) to basis points (0-10000)
83
+ */
84
+ export function percentToBps(percent: number): number {
85
+ return Math.floor(percent * 100)
86
+ }
3
87
 
4
88
  /**
5
89
  * Zod schema validation middleware factory
6
90
  */
7
91
  export function validateRequest(schema: {
8
- body?: ZodSchema
9
- query?: ZodSchema
10
- params?: ZodSchema
92
+ body?: ZodType
93
+ query?: ZodType
94
+ params?: ZodType
11
95
  }) {
12
96
  return async (req: Request, res: Response, next: NextFunction) => {
13
97
  try {
@@ -15,10 +99,14 @@ export function validateRequest(schema: {
15
99
  req.body = await schema.body.parseAsync(req.body)
16
100
  }
17
101
  if (schema.query) {
18
- req.query = await schema.query.parseAsync(req.query)
102
+ // Zod 4 returns unknown, cast to Express expected type
103
+ req.query = (await schema.query.parseAsync(req.query)) as typeof req.query
19
104
  }
20
105
  if (schema.params) {
21
- req.params = await schema.params.parseAsync(req.params)
106
+ // Zod 4 returns unknown, cast to Express expected type
107
+ req.params = (await schema.params.parseAsync(
108
+ req.params
109
+ )) as typeof req.params
22
110
  }
23
111
  next()
24
112
  } catch (error) {
@@ -28,7 +116,8 @@ export function validateRequest(schema: {
28
116
  error: {
29
117
  code: 'VALIDATION_ERROR',
30
118
  message: 'Invalid request data',
31
- details: error.errors,
119
+ // Zod 4 renamed 'errors' to 'issues'
120
+ details: error.issues,
32
121
  },
33
122
  })
34
123
  }
@@ -52,20 +141,20 @@ export const schemas = {
52
141
  }),
53
142
 
54
143
  createCommitment: z.object({
55
- value: z.string().regex(/^\d+$/), // bigint as string
144
+ value: amountSchema,
56
145
  blindingFactor: z.string().regex(/^0x[0-9a-fA-F]+$/).optional(),
57
146
  }),
58
147
 
59
148
  generateFundingProof: z.object({
60
- balance: z.string().regex(/^\d+$/),
61
- minRequired: z.string().regex(/^\d+$/),
149
+ balance: amountSchema,
150
+ minRequired: minAmountSchema, // Zero allowed: "prove I have >= 0" is valid
62
151
  balanceBlinding: z.string().regex(/^0x[0-9a-fA-F]+$/),
63
152
  }),
64
153
 
65
154
  getQuote: z.object({
66
155
  inputChain: z.enum(['solana', 'ethereum', 'near', 'zcash', 'polygon', 'arbitrum', 'optimism', 'base', 'bitcoin', 'aptos', 'sui', 'cosmos', 'osmosis', 'injective', 'celestia', 'sei', 'dydx']),
67
156
  inputToken: z.string().min(1),
68
- inputAmount: z.string().regex(/^\d+$/),
157
+ inputAmount: amountSchema,
69
158
  outputChain: z.enum(['solana', 'ethereum', 'near', 'zcash', 'polygon', 'arbitrum', 'optimism', 'base', 'bitcoin', 'aptos', 'sui', 'cosmos', 'osmosis', 'injective', 'celestia', 'sei', 'dydx']),
70
159
  outputToken: z.string().min(1),
71
160
  slippageTolerance: z.number().min(0).max(100).optional(),
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Monitoring Module
3
+ *
4
+ * Exports all monitoring functionality:
5
+ * - Sentry error tracking
6
+ * - Prometheus metrics
7
+ */
8
+
9
+ export {
10
+ initSentry,
11
+ isSentryEnabled,
12
+ captureException,
13
+ captureMessage,
14
+ setUser,
15
+ setTags,
16
+ setupSentryErrorHandler,
17
+ sentryRequestHandler,
18
+ sentryErrorHandler,
19
+ flushSentry,
20
+ Sentry,
21
+ } from './sentry'
22
+
23
+ export {
24
+ register,
25
+ httpRequestsTotal,
26
+ httpRequestDuration,
27
+ stealthAddressGenerations,
28
+ commitmentCreations,
29
+ proofGenerations,
30
+ proofGenerationDuration,
31
+ activeConnections,
32
+ swapRequests,
33
+ quoteRequests,
34
+ metricsMiddleware,
35
+ sipMetrics,
36
+ } from './metrics'
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Prometheus Metrics
3
+ *
4
+ * Exposes metrics for monitoring:
5
+ * - HTTP request count and latency
6
+ * - SIP-specific operation metrics
7
+ * - Default Node.js metrics (memory, CPU, event loop)
8
+ */
9
+
10
+ import { Registry, Counter, Histogram, collectDefaultMetrics, Gauge } from 'prom-client'
11
+ import { Request, Response, NextFunction } from 'express'
12
+
13
+ // Create a registry for all metrics
14
+ export const register = new Registry()
15
+
16
+ // Add default Node.js metrics
17
+ collectDefaultMetrics({
18
+ register,
19
+ prefix: 'sip_api_',
20
+ })
21
+
22
+ // HTTP request counter
23
+ export const httpRequestsTotal = new Counter({
24
+ name: 'sip_api_http_requests_total',
25
+ help: 'Total number of HTTP requests',
26
+ labelNames: ['method', 'path', 'status'] as const,
27
+ registers: [register],
28
+ })
29
+
30
+ // HTTP request duration histogram
31
+ export const httpRequestDuration = new Histogram({
32
+ name: 'sip_api_http_request_duration_seconds',
33
+ help: 'Duration of HTTP requests in seconds',
34
+ labelNames: ['method', 'path', 'status'] as const,
35
+ buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
36
+ registers: [register],
37
+ })
38
+
39
+ // SIP-specific metrics
40
+ export const stealthAddressGenerations = new Counter({
41
+ name: 'sip_stealth_address_generations_total',
42
+ help: 'Total number of stealth address generations',
43
+ labelNames: ['chain'] as const,
44
+ registers: [register],
45
+ })
46
+
47
+ export const commitmentCreations = new Counter({
48
+ name: 'sip_commitment_creations_total',
49
+ help: 'Total number of commitment creations',
50
+ registers: [register],
51
+ })
52
+
53
+ export const proofGenerations = new Counter({
54
+ name: 'sip_proof_generations_total',
55
+ help: 'Total number of proof generations',
56
+ labelNames: ['type'] as const,
57
+ registers: [register],
58
+ })
59
+
60
+ export const proofGenerationDuration = new Histogram({
61
+ name: 'sip_proof_generation_duration_seconds',
62
+ help: 'Duration of proof generation in seconds',
63
+ labelNames: ['type'] as const,
64
+ buckets: [0.1, 0.5, 1, 2.5, 5, 10, 30, 60],
65
+ registers: [register],
66
+ })
67
+
68
+ export const activeConnections = new Gauge({
69
+ name: 'sip_api_active_connections',
70
+ help: 'Number of active connections',
71
+ registers: [register],
72
+ })
73
+
74
+ export const swapRequests = new Counter({
75
+ name: 'sip_swap_requests_total',
76
+ help: 'Total number of swap requests',
77
+ labelNames: ['from_chain', 'to_chain', 'status'] as const,
78
+ registers: [register],
79
+ })
80
+
81
+ export const quoteRequests = new Counter({
82
+ name: 'sip_quote_requests_total',
83
+ help: 'Total number of quote requests',
84
+ labelNames: ['from_chain', 'to_chain'] as const,
85
+ registers: [register],
86
+ })
87
+
88
+ /**
89
+ * Normalize path to avoid high-cardinality labels
90
+ * Replaces dynamic segments like IDs with placeholders
91
+ */
92
+ function normalizePath(path: string): string {
93
+ return path
94
+ // Replace UUIDs
95
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
96
+ // Replace hex addresses (0x...)
97
+ .replace(/0x[0-9a-fA-F]{40,}/g, ':address')
98
+ // Replace numeric IDs
99
+ .replace(/\/\d+/g, '/:id')
100
+ // Collapse multiple slashes
101
+ .replace(/\/+/g, '/')
102
+ }
103
+
104
+ /**
105
+ * Express middleware to track HTTP metrics
106
+ */
107
+ export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void {
108
+ // Skip metrics endpoint itself to avoid recursion
109
+ if (req.path === '/metrics') {
110
+ return next()
111
+ }
112
+
113
+ const startTime = process.hrtime.bigint()
114
+
115
+ // Track active connections
116
+ activeConnections.inc()
117
+
118
+ // On response finish, record metrics
119
+ res.on('finish', () => {
120
+ const endTime = process.hrtime.bigint()
121
+ const durationSeconds = Number(endTime - startTime) / 1e9
122
+
123
+ const path = normalizePath(req.route?.path || req.path)
124
+ const labels = {
125
+ method: req.method,
126
+ path,
127
+ status: res.statusCode.toString(),
128
+ }
129
+
130
+ httpRequestsTotal.inc(labels)
131
+ httpRequestDuration.observe(labels, durationSeconds)
132
+
133
+ activeConnections.dec()
134
+ })
135
+
136
+ next()
137
+ }
138
+
139
+ /**
140
+ * Helper to record SIP operation metrics
141
+ */
142
+ export const sipMetrics = {
143
+ recordStealthGeneration(chain: string): void {
144
+ stealthAddressGenerations.inc({ chain })
145
+ },
146
+
147
+ recordCommitmentCreation(): void {
148
+ commitmentCreations.inc()
149
+ },
150
+
151
+ recordProofGeneration(type: string, durationSeconds: number): void {
152
+ proofGenerations.inc({ type })
153
+ proofGenerationDuration.observe({ type }, durationSeconds)
154
+ },
155
+
156
+ recordSwapRequest(fromChain: string, toChain: string, status: string): void {
157
+ swapRequests.inc({ from_chain: fromChain, to_chain: toChain, status })
158
+ },
159
+
160
+ recordQuoteRequest(fromChain: string, toChain: string): void {
161
+ quoteRequests.inc({ from_chain: fromChain, to_chain: toChain })
162
+ },
163
+ }