@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,179 @@
1
+ /**
2
+ * Sentry Error Monitoring
3
+ *
4
+ * Integrates Sentry for production error tracking:
5
+ * - Captures unhandled exceptions
6
+ * - Captures unhandled promise rejections
7
+ * - Provides stack traces with source maps
8
+ * - Enriches errors with request context
9
+ */
10
+
11
+ import * as Sentry from '@sentry/node'
12
+ import type { Express, ErrorRequestHandler } from 'express'
13
+ import { env } from '../config'
14
+
15
+ let isInitialized = false
16
+
17
+ /**
18
+ * Initialize Sentry error monitoring
19
+ *
20
+ * Only initializes if SENTRY_DSN is provided
21
+ */
22
+ export function initSentry(): void {
23
+ if (isInitialized) return
24
+
25
+ if (!env.SENTRY_DSN) {
26
+ if (env.isProduction) {
27
+ console.warn('[Sentry] SENTRY_DSN not set - error monitoring disabled')
28
+ }
29
+ return
30
+ }
31
+
32
+ Sentry.init({
33
+ dsn: env.SENTRY_DSN,
34
+ environment: env.NODE_ENV,
35
+ release: `sip-api@${process.env.npm_package_version || '0.1.0'}`,
36
+
37
+ // Performance monitoring
38
+ tracesSampleRate: env.isProduction ? 0.1 : 1.0,
39
+
40
+ // Filter out noisy errors
41
+ ignoreErrors: [
42
+ // Expected client errors
43
+ 'Request validation failed',
44
+ 'Unauthorized',
45
+ // Network issues
46
+ 'ECONNRESET',
47
+ 'EPIPE',
48
+ ],
49
+
50
+ // Add extra context
51
+ beforeSend(event) {
52
+ // Don't send events in test environment
53
+ if (env.NODE_ENV === 'test') {
54
+ return null
55
+ }
56
+
57
+ // Add request ID tag for correlation
58
+ const requestId = event.request?.headers?.['x-request-id']
59
+ if (requestId && typeof requestId === 'string') {
60
+ event.tags = { ...event.tags, requestId }
61
+ }
62
+
63
+ // Sanitize sensitive data
64
+ if (event.request?.headers) {
65
+ delete event.request.headers['authorization']
66
+ delete event.request.headers['x-api-key']
67
+ delete event.request.headers['cookie']
68
+ }
69
+
70
+ return event
71
+ },
72
+
73
+ // Capture breadcrumbs for debugging
74
+ beforeBreadcrumb(breadcrumb) {
75
+ // Filter out health check noise
76
+ if (breadcrumb.category === 'http' && breadcrumb.data?.url?.includes('/health')) {
77
+ return null
78
+ }
79
+ return breadcrumb
80
+ },
81
+ })
82
+
83
+ isInitialized = true
84
+ console.log('[Sentry] Initialized for environment:', env.NODE_ENV)
85
+ }
86
+
87
+ /**
88
+ * Check if Sentry is initialized
89
+ */
90
+ export function isSentryEnabled(): boolean {
91
+ return isInitialized && !!env.SENTRY_DSN
92
+ }
93
+
94
+ /**
95
+ * Capture an exception with optional context
96
+ */
97
+ export function captureException(
98
+ error: Error,
99
+ context?: Record<string, unknown>
100
+ ): string | undefined {
101
+ if (!isSentryEnabled()) return undefined
102
+
103
+ return Sentry.captureException(error, {
104
+ extra: context,
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Capture a message with severity level
110
+ */
111
+ export function captureMessage(
112
+ message: string,
113
+ level: Sentry.SeverityLevel = 'info'
114
+ ): string | undefined {
115
+ if (!isSentryEnabled()) return undefined
116
+
117
+ return Sentry.captureMessage(message, level)
118
+ }
119
+
120
+ /**
121
+ * Set user context for error tracking
122
+ */
123
+ export function setUser(user: { id: string; [key: string]: unknown } | null): void {
124
+ if (!isSentryEnabled()) return
125
+
126
+ Sentry.setUser(user)
127
+ }
128
+
129
+ /**
130
+ * Add context tags
131
+ */
132
+ export function setTags(tags: Record<string, string>): void {
133
+ if (!isSentryEnabled()) return
134
+
135
+ Sentry.setTags(tags)
136
+ }
137
+
138
+ /**
139
+ * Setup Express error handler for Sentry
140
+ * Call after all routes are registered
141
+ */
142
+ export function setupSentryErrorHandler(app: Express): void {
143
+ if (!isSentryEnabled()) return
144
+
145
+ Sentry.setupExpressErrorHandler(app)
146
+ }
147
+
148
+ /**
149
+ * Create a no-op request handler middleware
150
+ * Sentry v10 uses auto-instrumentation, but we keep this for compatibility
151
+ */
152
+ export function sentryRequestHandler(
153
+ _req: unknown,
154
+ _res: unknown,
155
+ next: () => void
156
+ ): void {
157
+ next()
158
+ }
159
+
160
+ /**
161
+ * Create a no-op error handler for compatibility
162
+ * Actual error handling is done via setupSentryErrorHandler
163
+ */
164
+ export const sentryErrorHandler: ErrorRequestHandler = (err, _req, _res, next) => {
165
+ // Sentry v10 captures errors automatically via setupExpressErrorHandler
166
+ // This middleware is kept for API compatibility
167
+ next(err)
168
+ }
169
+
170
+ /**
171
+ * Flush pending events (call before shutdown)
172
+ */
173
+ export async function flushSentry(timeout = 2000): Promise<boolean> {
174
+ if (!isSentryEnabled()) return true
175
+
176
+ return Sentry.close(timeout)
177
+ }
178
+
179
+ export { Sentry }
@@ -45,7 +45,7 @@ router.post(
45
45
  const { value, blindingFactor } = req.body as CreateCommitmentRequest
46
46
 
47
47
  const valueBigInt = BigInt(value)
48
- const blindingBytes = blindingFactor ? hexToBytes(blindingFactor) : undefined
48
+ const blindingBytes = blindingFactor ? hexToBytes(blindingFactor.replace(/^0x/, '')) : undefined
49
49
 
50
50
  const result = commit(valueBigInt, blindingBytes)
51
51
 
@@ -1,5 +1,9 @@
1
1
  import { Router, Request, Response } from 'express'
2
2
  import type { HealthResponse, ApiResponse } from '../types/api'
3
+ import { isServerShuttingDown } from '../shutdown'
4
+ import { swapStore } from '../stores'
5
+ import { isProofProviderReady, getProofInitError } from './proof'
6
+ import { getRedisStatus } from '../middleware/rate-limit'
3
7
 
4
8
  const router: Router = Router()
5
9
 
@@ -38,17 +42,45 @@ const startTime = Date.now()
38
42
  * type: number
39
43
  */
40
44
  router.get('/', (req: Request, res: Response) => {
45
+ const shuttingDown = isServerShuttingDown()
46
+ const status = shuttingDown ? 'shutting_down' : 'healthy'
47
+ const statusCode = shuttingDown ? 503 : 200
48
+
49
+ const cacheMetrics = swapStore.getMetrics()
50
+
51
+ const proofReady = isProofProviderReady()
52
+ const proofError = getProofInitError()
53
+ const redisStatus = getRedisStatus()
54
+
41
55
  const response: ApiResponse<HealthResponse> = {
42
- success: true,
56
+ success: !shuttingDown,
43
57
  data: {
44
- status: 'healthy',
58
+ status: status as 'healthy',
45
59
  version: process.env.npm_package_version || '0.1.0',
46
60
  timestamp: new Date().toISOString(),
47
61
  uptime: Math.floor((Date.now() - startTime) / 1000),
62
+ services: {
63
+ proofProvider: {
64
+ ready: proofReady,
65
+ error: proofError?.message || null,
66
+ },
67
+ rateLimiter: {
68
+ store: redisStatus.storeType,
69
+ redisConfigured: redisStatus.configured,
70
+ redisConnected: redisStatus.connected,
71
+ },
72
+ },
73
+ cache: {
74
+ swaps: {
75
+ size: cacheMetrics.size,
76
+ maxSize: cacheMetrics.maxSize,
77
+ utilizationPercent: cacheMetrics.utilizationPercent,
78
+ },
79
+ },
48
80
  },
49
81
  }
50
82
 
51
- res.json(response)
83
+ res.status(statusCode).json(response)
52
84
  })
53
85
 
54
86
  export default router
@@ -4,6 +4,7 @@ import stealthRouter from './stealth'
4
4
  import commitmentRouter from './commitment'
5
5
  import proofRouter from './proof'
6
6
  import swapRouter from './swap'
7
+ import webhookRouter from './webhook'
7
8
 
8
9
  const router: Router = Router()
9
10
 
@@ -14,5 +15,6 @@ router.use('/commitment', commitmentRouter)
14
15
  router.use('/proof', proofRouter)
15
16
  router.use('/quote', swapRouter) // POST /quote
16
17
  router.use('/swap', swapRouter) // POST /swap and GET /swap/:id/status
18
+ router.use('/webhooks', webhookRouter) // Webhook registration CRUD + Helius ingest
17
19
 
18
20
  export default router
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Prometheus Metrics Endpoint
3
+ *
4
+ * GET /metrics - Prometheus-compatible metrics
5
+ */
6
+
7
+ import { Router, Request, Response } from 'express'
8
+ import { register } from '../monitoring'
9
+
10
+ const router: Router = Router()
11
+
12
+ /**
13
+ * GET /metrics
14
+ * Prometheus metrics endpoint
15
+ *
16
+ * Returns metrics in Prometheus text format for scraping
17
+ */
18
+ router.get('/', async (req: Request, res: Response) => {
19
+ try {
20
+ res.set('Content-Type', register.contentType)
21
+ res.end(await register.metrics())
22
+ } catch (err) {
23
+ res.status(500).end()
24
+ }
25
+ })
26
+
27
+ export default router
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'
2
2
  import { MockProofProvider } from '@sip-protocol/sdk'
3
3
  import { hexToBytes } from '@noble/hashes/utils'
4
4
  import { validateRequest, schemas } from '../middleware'
5
+ import { logger } from '../logger'
5
6
  import type { GenerateFundingProofRequest, FundingProofResponse, ApiResponse } from '../types/api'
6
7
 
7
8
  const router: Router = Router()
@@ -10,6 +11,58 @@ const router: Router = Router()
10
11
  // In production, use NoirProofProvider from '@sip-protocol/sdk/proofs/noir'
11
12
  const proofProvider = new MockProofProvider()
12
13
 
14
+ /**
15
+ * Proof provider initialization state
16
+ * Implements fail-fast pattern with retry for transient failures
17
+ */
18
+ let proofProviderReady = false
19
+ let proofInitError: Error | null = null
20
+
21
+ const MAX_INIT_RETRIES = 3
22
+ const RETRY_DELAY_MS = 2000
23
+
24
+ async function initializeProofProvider(): Promise<void> {
25
+ for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
26
+ try {
27
+ await proofProvider.initialize()
28
+ proofProviderReady = true
29
+ proofInitError = null
30
+ logger.info({ attempt }, 'Proof provider initialized successfully')
31
+ return
32
+ } catch (err) {
33
+ proofInitError = err instanceof Error ? err : new Error(String(err))
34
+ logger.warn({ attempt, maxRetries: MAX_INIT_RETRIES, error: proofInitError.message },
35
+ 'Proof provider initialization failed, retrying...')
36
+
37
+ if (attempt < MAX_INIT_RETRIES) {
38
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * attempt))
39
+ }
40
+ }
41
+ }
42
+
43
+ // All retries exhausted - log fatal but don't crash (graceful degradation)
44
+ // The readiness guard will reject proof requests until fixed
45
+ logger.error({ error: proofInitError?.message },
46
+ 'Proof provider initialization failed after all retries')
47
+ }
48
+
49
+ // Start initialization immediately (non-blocking)
50
+ initializeProofProvider()
51
+
52
+ /**
53
+ * Check if proof provider is ready
54
+ */
55
+ export function isProofProviderReady(): boolean {
56
+ return proofProviderReady
57
+ }
58
+
59
+ /**
60
+ * Get proof provider initialization error (if any)
61
+ */
62
+ export function getProofInitError(): Error | null {
63
+ return proofInitError
64
+ }
65
+
13
66
  /**
14
67
  * POST /proof/funding
15
68
  * Generate a funding proof (proves balance >= minimum without revealing exact balance)
@@ -52,11 +105,25 @@ router.post(
52
105
  '/funding',
53
106
  validateRequest({ body: schemas.generateFundingProof }),
54
107
  async (req: Request, res: Response) => {
108
+ // Readiness guard - reject requests if proof provider not initialized
109
+ if (!proofProviderReady) {
110
+ const errorMsg = proofInitError?.message || 'Proof provider is initializing'
111
+ logger.warn({ error: errorMsg }, 'Proof request rejected - provider not ready')
112
+ return res.status(503).json({
113
+ success: false,
114
+ error: {
115
+ code: 'PROOF_PROVIDER_NOT_READY',
116
+ message: 'Proof generation service is not ready',
117
+ details: { reason: errorMsg },
118
+ },
119
+ })
120
+ }
121
+
55
122
  const { balance, minRequired, balanceBlinding } = req.body as GenerateFundingProofRequest
56
123
 
57
124
  const balanceBigInt = BigInt(balance)
58
125
  const minRequiredBigInt = BigInt(minRequired)
59
- const balanceBlindingBytes = hexToBytes(balanceBlinding)
126
+ const balanceBlindingBytes = hexToBytes(balanceBlinding.replace(/^0x/, ''))
60
127
 
61
128
  const result = await proofProvider.generateFundingProof({
62
129
  balance: balanceBigInt,
@@ -1,6 +1,9 @@
1
1
  import { Router, Request, Response } from 'express'
2
- import { SIP, PrivacyLevel } from '@sip-protocol/sdk'
3
- import { validateRequest, schemas } from '../middleware'
2
+ import { SIP, PrivacyLevel, getAsset, isKnownToken } from '@sip-protocol/sdk'
3
+ import { validateRequest, schemas, calculateMinAmount, percentToBps } from '../middleware'
4
+ import { swapStore } from '../stores'
5
+ import { env } from '../config'
6
+ import { logger } from '../logger'
4
7
  import type {
5
8
  GetQuoteRequest,
6
9
  ExecuteSwapRequest,
@@ -15,19 +18,15 @@ const router: Router = Router()
15
18
  // Initialize SIP client
16
19
  const sip = new SIP({ network: 'testnet' })
17
20
 
18
- import type { HexString } from '@sip-protocol/types'
21
+ /**
22
+ * Check if mock mode is allowed
23
+ * In production, mock mode should be explicitly disabled
24
+ */
25
+ const MOCK_MODE_ENABLED = env.NODE_ENV !== 'production'
19
26
 
20
- // In-memory swap tracking (in production, use a database)
21
- const swaps = new Map<string, {
22
- id: string
23
- status: 'pending' | 'processing' | 'completed' | 'failed'
24
- transactionHash?: HexString
25
- inputAmount: string
26
- outputAmount?: string
27
- createdAt: string
28
- updatedAt: string
29
- error?: string
30
- }>()
27
+ if (!MOCK_MODE_ENABLED) {
28
+ logger.warn('Production mode: Mock quotes and swaps are DISABLED')
29
+ }
31
30
 
32
31
  /**
33
32
  * POST /quote
@@ -82,36 +81,78 @@ router.post(
82
81
  slippageTolerance
83
82
  } = req.body as GetQuoteRequest
84
83
 
85
- // Create intent
84
+ // PRODUCTION MODE: Fail if quote aggregator not configured
85
+ if (!MOCK_MODE_ENABLED) {
86
+ return res.status(503).json({
87
+ success: false,
88
+ error: {
89
+ code: 'QUOTE_SERVICE_UNAVAILABLE',
90
+ message: 'Real quote aggregator not configured. This API is not ready for production use.',
91
+ details: {
92
+ hint: 'Configure QUOTE_AGGREGATOR_URL or use development mode',
93
+ },
94
+ },
95
+ })
96
+ }
97
+
98
+ // Validate tokens are known (fail fast on unknown tokens)
99
+ if (!isKnownToken(inputToken, inputChain)) {
100
+ return res.status(400).json({
101
+ success: false,
102
+ error: {
103
+ code: 'UNKNOWN_TOKEN',
104
+ message: `Unknown input token: ${inputToken} on ${inputChain}`,
105
+ },
106
+ } satisfies ApiResponse<never>)
107
+ }
108
+ if (!isKnownToken(outputToken, outputChain)) {
109
+ return res.status(400).json({
110
+ success: false,
111
+ error: {
112
+ code: 'UNKNOWN_TOKEN',
113
+ message: `Unknown output token: ${outputToken} on ${outputChain}`,
114
+ },
115
+ } satisfies ApiResponse<never>)
116
+ }
117
+
118
+ // Get asset info with correct decimals from registry
119
+ const inputAsset = getAsset(inputToken, inputChain)
120
+ const outputAsset = getAsset(outputToken, outputChain)
121
+
122
+ // Parse and validate amount (already validated by schema, safe to parse)
123
+ const inputAmountBigInt = BigInt(inputAmount)
124
+ const slippagePercent = slippageTolerance ?? 1
125
+ const slippageBps = percentToBps(slippagePercent)
126
+
127
+ // Create intent with safe slippage calculation
86
128
  const intent = await sip.createIntent({
87
129
  input: {
88
- asset: {
89
- chain: inputChain,
90
- address: null, // Native token
91
- symbol: inputToken,
92
- decimals: 9,
93
- },
94
- amount: BigInt(inputAmount),
130
+ asset: inputAsset,
131
+ amount: inputAmountBigInt,
95
132
  },
96
133
  output: {
97
- asset: {
98
- chain: outputChain,
99
- address: null, // Native token
100
- symbol: outputToken,
101
- decimals: 9,
102
- },
103
- minAmount: BigInt(inputAmount) * 95n / 100n, // 5% slippage
104
- maxSlippage: (slippageTolerance || 1) / 100,
134
+ asset: outputAsset,
135
+ minAmount: calculateMinAmount(inputAmountBigInt, slippageBps),
136
+ maxSlippage: slippagePercent / 100,
105
137
  },
106
138
  privacy: PrivacyLevel.TRANSPARENT, // Default to transparent for quote
107
139
  })
108
140
 
109
- // Get quotes (using mock data for now)
110
- // In production, this would query real DEX aggregators
141
+ // DEV MODE: Return mock quote with warning
142
+ // In production, this block would never execute (see check above)
143
+ logger.warn({
144
+ inputChain,
145
+ inputToken,
146
+ outputChain,
147
+ outputToken,
148
+ inputAmount,
149
+ }, 'Returning MOCK quote - not for production use')
150
+
111
151
  const mockQuote: QuoteResponse = {
112
152
  quoteId: `quote-${Date.now()}`,
113
153
  inputAmount,
114
- outputAmount: (BigInt(inputAmount) * 95n / 100n).toString(), // Mock 5% fee
154
+ // Mock: 5% fee (clearly fake rate)
155
+ outputAmount: (BigInt(inputAmount) * 95n / 100n).toString(),
115
156
  rate: '0.95',
116
157
  estimatedTime: 30,
117
158
  fees: {
@@ -130,9 +171,12 @@ router.post(
130
171
  },
131
172
  }
132
173
 
133
- const response: ApiResponse<QuoteResponse> = {
174
+ const response: ApiResponse<QuoteResponse & { _warning?: string }> = {
134
175
  success: true,
135
- data: mockQuote,
176
+ data: {
177
+ ...mockQuote,
178
+ _warning: 'MOCK_DATA: This quote uses simulated pricing. Do not use for real transactions.',
179
+ },
136
180
  }
137
181
 
138
182
  res.json(response)
@@ -176,33 +220,56 @@ router.post(
176
220
  '/swap',
177
221
  validateRequest({ body: schemas.executeSwap }),
178
222
  async (req: Request, res: Response) => {
179
- const { intentId, quoteId, privacy, viewingKey } = req.body as ExecuteSwapRequest
223
+ const { intentId, quoteId, inputAmount } = req.body as ExecuteSwapRequest & { inputAmount?: string }
224
+
225
+ // PRODUCTION MODE: Fail if swap executor not configured
226
+ if (!MOCK_MODE_ENABLED) {
227
+ return res.status(503).json({
228
+ success: false,
229
+ error: {
230
+ code: 'SWAP_SERVICE_UNAVAILABLE',
231
+ message: 'Real swap executor not configured. This API is not ready for production use.',
232
+ details: {
233
+ hint: 'Configure SWAP_EXECUTOR_URL or use development mode',
234
+ },
235
+ },
236
+ })
237
+ }
180
238
 
181
239
  // Generate swap ID
182
240
  const swapId = `swap-${Date.now()}`
183
241
 
184
- // Store swap status
242
+ // Track actual input amount from request (not hardcoded)
243
+ // In dev mode, allow a default for testing but warn about it
244
+ const actualInputAmount = inputAmount || '0'
245
+ if (!inputAmount) {
246
+ logger.warn({ swapId, quoteId, intentId }, 'Swap created without inputAmount - using 0')
247
+ }
248
+
249
+ // Store swap in LRU cache with TTL
185
250
  const swap = {
186
251
  id: swapId,
187
252
  status: 'pending' as const,
188
- inputAmount: '1000000000', // Mock value
253
+ inputAmount: actualInputAmount,
189
254
  createdAt: new Date().toISOString(),
190
255
  updatedAt: new Date().toISOString(),
191
256
  }
192
- swaps.set(swapId, swap)
257
+ swapStore.set(swapId, swap)
193
258
 
194
- // In production, this would:
195
- // 1. Sign the transaction
196
- // 2. Submit to the network
197
- // 3. Track the transaction
198
- // For now, we just return a mock response
259
+ logger.warn({
260
+ swapId,
261
+ quoteId,
262
+ intentId,
263
+ inputAmount: actualInputAmount,
264
+ }, 'Creating MOCK swap - not for production use')
199
265
 
200
- const response: ApiResponse<SwapResponse> = {
266
+ const response: ApiResponse<SwapResponse & { _warning?: string }> = {
201
267
  success: true,
202
268
  data: {
203
269
  swapId,
204
270
  status: 'pending',
205
271
  timestamp: new Date().toISOString(),
272
+ _warning: 'MOCK_DATA: This swap is simulated. No real transaction will be executed.',
206
273
  },
207
274
  }
208
275
 
@@ -237,15 +304,17 @@ router.get(
237
304
  validateRequest({ params: schemas.swapStatus }),
238
305
  async (req: Request, res: Response) => {
239
306
  const { id } = req.params
307
+ // Express 5 types params as string | string[] - ensure we have a string
308
+ const swapId = Array.isArray(id) ? id[0] : id
240
309
 
241
- const swap = swaps.get(id)
310
+ const swap = swapStore.get(swapId)
242
311
 
243
312
  if (!swap) {
244
313
  return res.status(404).json({
245
314
  success: false,
246
315
  error: {
247
316
  code: 'SWAP_NOT_FOUND',
248
- message: `Swap ${id} not found`,
317
+ message: `Swap ${swapId} not found`,
249
318
  },
250
319
  })
251
320
  }