@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.
- package/README.md +8 -0
- package/dist/routes/index.js +1164 -145
- package/dist/server.d.ts +29 -1
- package/dist/server.js +1514 -173
- package/package.json +25 -11
- package/src/config.ts +121 -0
- package/src/logger.ts +99 -0
- package/src/middleware/auth.ts +128 -0
- package/src/middleware/cors.ts +163 -0
- package/src/middleware/error-handler.ts +18 -7
- package/src/middleware/index.ts +13 -1
- package/src/middleware/rate-limit.ts +203 -0
- package/src/middleware/request-id.ts +66 -0
- package/src/middleware/validation.ts +100 -11
- package/src/monitoring/index.ts +36 -0
- package/src/monitoring/metrics.ts +163 -0
- package/src/monitoring/sentry.ts +179 -0
- package/src/routes/commitment.ts +1 -1
- package/src/routes/health.ts +35 -3
- package/src/routes/index.ts +2 -0
- package/src/routes/metrics.ts +27 -0
- package/src/routes/proof.ts +68 -1
- package/src/routes/swap.ts +116 -47
- package/src/routes/webhook.ts +156 -0
- package/src/server.ts +142 -19
- package/src/services/helius-listener.ts +104 -0
- package/src/services/index.ts +15 -0
- package/src/services/token-metadata.ts +91 -0
- package/src/services/webhook-delivery.ts +146 -0
- package/src/shutdown.ts +119 -0
- package/src/stores/index.ts +2 -0
- package/src/stores/swap-store.ts +158 -0
- package/src/stores/webhook-store.ts +120 -0
- package/src/types/api.ts +67 -1
|
@@ -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
|
|
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?:
|
|
9
|
-
query?:
|
|
10
|
-
params?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
61
|
-
minRequired:
|
|
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:
|
|
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
|
+
}
|