@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,156 @@
1
+ /**
2
+ * Webhook Routes
3
+ *
4
+ * CRUD for agent webhook registrations + Helius ingest endpoint.
5
+ *
6
+ * POST /webhooks/register — Register a webhook
7
+ * DELETE /webhooks/:id — Unregister a webhook
8
+ * GET /webhooks — List registered webhooks
9
+ * POST /internal/helius — Helius webhook callback (no API key auth)
10
+ */
11
+
12
+ import { Router, Request, Response } from 'express'
13
+ import { z } from 'zod'
14
+ import { validateRequest } from '../middleware'
15
+ import { webhookStore } from '../stores/webhook-store'
16
+ import { heliusListenerService } from '../services/helius-listener'
17
+ import type {
18
+ ApiResponse,
19
+ RegisterWebhookResponse,
20
+ WebhookListItem,
21
+ } from '../types/api'
22
+
23
+ const router: Router = Router()
24
+
25
+ /**
26
+ * Validation schemas for webhook endpoints
27
+ */
28
+ const webhookSchemas = {
29
+ register: z.object({
30
+ url: z.string().url('Must be a valid URL'),
31
+ viewingPrivateKey: z
32
+ .string()
33
+ .regex(/^0x[0-9a-fA-F]{64}$/, 'Must be 0x-prefixed 32-byte hex'),
34
+ spendingPublicKey: z
35
+ .string()
36
+ .regex(/^0x[0-9a-fA-F]{64}$/, 'Must be 0x-prefixed 32-byte hex'),
37
+ }),
38
+
39
+ unregister: z.object({
40
+ id: z.string().min(1),
41
+ }),
42
+ }
43
+
44
+ /**
45
+ * POST /webhooks/register
46
+ * Register a webhook to receive payment notifications
47
+ */
48
+ router.post(
49
+ '/register',
50
+ validateRequest({ body: webhookSchemas.register }),
51
+ (req: Request, res: Response) => {
52
+ const { url, viewingPrivateKey, spendingPublicKey } = req.body
53
+
54
+ try {
55
+ const registration = webhookStore.register(url, viewingPrivateKey, spendingPublicKey)
56
+
57
+ const response: ApiResponse<RegisterWebhookResponse> = {
58
+ success: true,
59
+ data: {
60
+ id: registration.id,
61
+ url: registration.url,
62
+ secret: registration.secret,
63
+ createdAt: registration.createdAt,
64
+ },
65
+ }
66
+
67
+ res.status(201).json(response)
68
+ } catch (error) {
69
+ if ((error as Error).message === 'WEBHOOK_STORE_FULL') {
70
+ res.status(503).json({
71
+ success: false,
72
+ error: {
73
+ code: 'WEBHOOK_STORE_FULL',
74
+ message: 'Maximum webhook registrations reached',
75
+ },
76
+ })
77
+ return
78
+ }
79
+ throw error
80
+ }
81
+ }
82
+ )
83
+
84
+ /**
85
+ * DELETE /webhooks/:id
86
+ * Unregister a webhook
87
+ */
88
+ router.delete(
89
+ '/:id',
90
+ validateRequest({ params: webhookSchemas.unregister }),
91
+ (req: Request, res: Response) => {
92
+ const removed = webhookStore.unregister(req.params.id as string)
93
+
94
+ if (!removed) {
95
+ res.status(404).json({
96
+ success: false,
97
+ error: {
98
+ code: 'WEBHOOK_NOT_FOUND',
99
+ message: 'Webhook not found',
100
+ },
101
+ })
102
+ return
103
+ }
104
+
105
+ res.status(204).send()
106
+ }
107
+ )
108
+
109
+ /**
110
+ * GET /webhooks
111
+ * List all registered webhooks (never exposes keys or secret)
112
+ */
113
+ router.get('/', (_req: Request, res: Response) => {
114
+ const webhooks = webhookStore.getAllForList()
115
+
116
+ const response: ApiResponse<{ webhooks: WebhookListItem[] }> = {
117
+ success: true,
118
+ data: { webhooks },
119
+ }
120
+
121
+ res.json(response)
122
+ })
123
+
124
+ /**
125
+ * POST /internal/helius
126
+ * Helius webhook callback endpoint
127
+ *
128
+ * Authentication: Helius HMAC signature (not API key).
129
+ * This path is added to AUTH_SKIP_PATHS in server.ts.
130
+ * Returns 200 immediately to Helius; processing is async.
131
+ */
132
+ router.post('/internal/helius', async (req: Request, res: Response) => {
133
+ const headers = {
134
+ signature: req.headers['x-helius-signature'] as string | undefined,
135
+ rawBody: typeof req.body === 'string' ? req.body : JSON.stringify(req.body),
136
+ }
137
+
138
+ try {
139
+ const matchCount = await heliusListenerService.processIncoming(req.body, headers)
140
+ res.status(200).json({ success: true, matched: matchCount })
141
+ } catch (error) {
142
+ if ((error as Error).message === 'HELIUS_SIGNATURE_INVALID') {
143
+ res.status(401).json({
144
+ success: false,
145
+ error: {
146
+ code: 'HELIUS_SIGNATURE_INVALID',
147
+ message: 'Invalid Helius webhook signature',
148
+ },
149
+ })
150
+ return
151
+ }
152
+ throw error
153
+ }
154
+ })
155
+
156
+ export default router
package/src/server.ts CHANGED
@@ -1,24 +1,79 @@
1
+ /**
2
+ * SIP Protocol REST API Server
3
+ *
4
+ * Production-ready Express server with:
5
+ * - Structured logging (pino)
6
+ * - Environment validation (envalid)
7
+ * - Graceful shutdown handling
8
+ * - Security middleware (helmet, CORS, rate limiting)
9
+ * - Request authentication
10
+ * - Error monitoring (Sentry)
11
+ * - Metrics collection (Prometheus)
12
+ */
13
+
1
14
  import express, { Express } from 'express'
2
- import cors from 'cors'
3
15
  import helmet from 'helmet'
4
16
  import compression from 'compression'
5
- import morgan from 'morgan'
6
17
  import router from './routes'
7
- import { errorHandler, notFoundHandler } from './middleware'
18
+ import metricsRouter from './routes/metrics'
19
+ import { env, logConfigWarnings } from './config'
20
+ import { logger, requestLogger } from './logger'
21
+ import { setupGracefulShutdown, shutdownMiddleware, isServerShuttingDown } from './shutdown'
22
+ import {
23
+ errorHandler,
24
+ notFoundHandler,
25
+ secureCors,
26
+ rateLimiter,
27
+ authenticate,
28
+ isAuthEnabled,
29
+ getCorsConfig,
30
+ requestIdMiddleware,
31
+ } from './middleware'
32
+ import {
33
+ initSentry,
34
+ setupSentryErrorHandler,
35
+ flushSentry,
36
+ isSentryEnabled,
37
+ metricsMiddleware,
38
+ } from './monitoring'
39
+ import { webhookDeliveryService } from './services/webhook-delivery'
40
+
41
+ // Initialize Sentry early (before Express)
42
+ initSentry()
8
43
 
9
44
  const app: Express = express()
10
45
 
11
- // Environment configuration
12
- const PORT = process.env.PORT || 3000
13
- const NODE_ENV = process.env.NODE_ENV || 'development'
14
- const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'
46
+ // Trust proxy configuration for X-Forwarded-For header
47
+ // SECURITY: Required for rate limiting to work behind reverse proxy (nginx, Cloudflare, etc.)
48
+ // Without this, rate limiting is trivially bypassable via X-Forwarded-For spoofing
49
+ const trustProxy = env.TRUST_PROXY
50
+ if (trustProxy !== 'false') {
51
+ // Parse as number if it looks like a number, otherwise use as string
52
+ const parsedValue = /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) : trustProxy
53
+ app.set('trust proxy', parsedValue)
54
+ logger.info({ trustProxy: parsedValue }, 'Proxy trust configured')
55
+ }
56
+
57
+ // Metrics middleware (early to capture all requests)
58
+ if (env.METRICS_ENABLED === 'true') {
59
+ app.use(metricsMiddleware)
60
+ }
61
+
62
+ // Shutdown middleware (early to reject during shutdown)
63
+ app.use(shutdownMiddleware)
64
+
65
+ // Request ID middleware (early for correlation)
66
+ app.use(requestIdMiddleware)
15
67
 
16
68
  // Security middleware
17
69
  app.use(helmet())
18
- app.use(cors({
19
- origin: CORS_ORIGIN,
20
- credentials: true,
21
- }))
70
+ app.use(secureCors)
71
+
72
+ // Rate limiting (before auth to prevent brute force)
73
+ app.use(rateLimiter)
74
+
75
+ // Authentication
76
+ app.use(authenticate)
22
77
 
23
78
  // Body parsing middleware
24
79
  app.use(express.json({ limit: '1mb' }))
@@ -27,14 +82,20 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }))
27
82
  // Compression middleware
28
83
  app.use(compression())
29
84
 
30
- // Logging middleware
31
- app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'combined'))
85
+ // Request logging (replaces morgan)
86
+ app.use(requestLogger)
32
87
 
33
88
  // API routes
34
89
  app.use('/api/v1', router)
35
90
 
91
+ // Metrics endpoint (no auth required for Prometheus scraping)
92
+ if (env.METRICS_ENABLED === 'true') {
93
+ app.use('/metrics', metricsRouter)
94
+ }
95
+
36
96
  // Root endpoint
37
97
  app.get('/', (req, res) => {
98
+ const corsConfig = getCorsConfig()
38
99
  res.json({
39
100
  name: '@sip-protocol/api',
40
101
  version: '0.1.0',
@@ -48,6 +109,18 @@ app.get('/', (req, res) => {
48
109
  quote: 'POST /api/v1/quote',
49
110
  swap: 'POST /api/v1/swap',
50
111
  swapStatus: 'GET /api/v1/swap/:id/status',
112
+ webhookRegister: 'POST /api/v1/webhooks/register',
113
+ webhookUnregister: 'DELETE /api/v1/webhooks/:id',
114
+ webhookList: 'GET /api/v1/webhooks',
115
+ heliusIngest: 'POST /api/v1/webhooks/internal/helius',
116
+ },
117
+ security: {
118
+ authentication: isAuthEnabled() ? 'enabled' : 'disabled',
119
+ cors: {
120
+ origins: corsConfig.origins.length > 0 ? corsConfig.origins.length + ' configured' : 'none (blocked)',
121
+ credentials: corsConfig.credentials,
122
+ },
123
+ rateLimit: 'enabled',
51
124
  },
52
125
  })
53
126
  })
@@ -55,22 +128,72 @@ app.get('/', (req, res) => {
55
128
  // 404 handler
56
129
  app.use(notFoundHandler)
57
130
 
131
+ // Sentry error handler (uses Sentry v10 auto-instrumentation)
132
+ setupSentryErrorHandler(app)
133
+
58
134
  // Error handler (must be last)
59
135
  app.use(errorHandler)
60
136
 
61
137
  // Start server
62
138
  if (require.main === module) {
63
- app.listen(PORT, () => {
64
- console.log(`
139
+ // Log configuration warnings
140
+ logConfigWarnings(logger)
141
+
142
+ const corsConfig = getCorsConfig()
143
+ const server = app.listen(env.PORT, () => {
144
+ logger.info({
145
+ port: env.PORT,
146
+ environment: env.NODE_ENV,
147
+ auth: isAuthEnabled() ? 'enabled' : 'disabled',
148
+ corsOrigins: corsConfig.origins.length,
149
+ logLevel: env.LOG_LEVEL,
150
+ sentry: isSentryEnabled() ? 'enabled' : 'disabled',
151
+ metrics: env.METRICS_ENABLED === 'true' ? 'enabled' : 'disabled',
152
+ }, 'SIP Protocol API started')
153
+
154
+ // Pretty banner in development
155
+ if (env.isDevelopment) {
156
+ console.log(`
65
157
  ╔════════════════════════════════════════════════════╗
66
158
  ║ SIP Protocol REST API ║
67
159
  ║ Version: 0.1.0 ║
68
- ║ Port: ${PORT} ║
69
- Environment: ${NODE_ENV}
70
- Documentation: http://localhost:${PORT}/
160
+ ╠════════════════════════════════════════════════════╣
161
+ Port: ${String(env.PORT).padEnd(43)}║
162
+ Environment: ${env.NODE_ENV.padEnd(37)}║
163
+ ╠════════════════════════════════════════════════════╣
164
+ ║ Security: ║
165
+ ║ • Auth: ${(isAuthEnabled() ? 'ENABLED' : 'disabled (dev mode)').padEnd(42)}║
166
+ ║ • CORS: ${(corsConfig.origins.length + ' origins').padEnd(42)}║
167
+ ║ • Rate Limit: enabled ║
168
+ ╠════════════════════════════════════════════════════╣
169
+ ║ Monitoring: ║
170
+ ║ • Sentry: ${(isSentryEnabled() ? 'ENABLED' : 'disabled').padEnd(40)}║
171
+ ║ • Metrics: ${(env.METRICS_ENABLED === 'true' ? '/metrics' : 'disabled').padEnd(39)}║
172
+ ╠════════════════════════════════════════════════════╣
173
+ ║ Logging: ${env.LOG_LEVEL.padEnd(41)}║
174
+ ╠════════════════════════════════════════════════════╣
175
+ ║ Documentation: http://localhost:${String(env.PORT).padEnd(17)}║
71
176
  ╚════════════════════════════════════════════════════╝
72
- `)
177
+ `)
178
+ }
179
+ })
180
+
181
+ // Setup graceful shutdown
182
+ setupGracefulShutdown(server, async () => {
183
+ // Drain pending webhook deliveries
184
+ logger.info('Draining webhook deliveries...')
185
+ await webhookDeliveryService.drainPending()
186
+
187
+ // Flush Sentry events before shutdown
188
+ if (isSentryEnabled()) {
189
+ logger.info('Flushing Sentry events...')
190
+ await flushSentry(2000)
191
+ }
192
+ logger.info('Flushing logs...')
193
+ // pino handles its own flushing on process exit
73
194
  })
74
195
  }
75
196
 
197
+ // Export for testing
76
198
  export default app
199
+ export { isServerShuttingDown }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Helius Webhook Listener Service
3
+ *
4
+ * Ingests raw Helius webhook payloads and fans out to registered
5
+ * agent webhooks. For each active registration, checks if the
6
+ * transaction contains a stealth payment for that agent.
7
+ *
8
+ * Uses the SDK's processWebhookTransaction() for payment detection
9
+ * and verifyWebhookSignature() for Helius signature verification.
10
+ */
11
+
12
+ import {
13
+ processWebhookTransaction,
14
+ verifyWebhookSignature,
15
+ } from '@sip-protocol/sdk'
16
+ import type { HeliusWebhookTransaction } from '@sip-protocol/sdk'
17
+ import { webhookStore } from '../stores/webhook-store'
18
+ import { webhookDeliveryService } from './webhook-delivery'
19
+ import { logger } from '../logger'
20
+ import { env } from '../config'
21
+
22
+ export class HeliusListenerService {
23
+ /**
24
+ * Process an incoming Helius webhook payload
25
+ *
26
+ * 1. Verify Helius signature (if configured)
27
+ * 2. For each active registration, check if payment is for them
28
+ * 3. On match, queue delivery to the agent's URL
29
+ *
30
+ * @returns Number of matched deliveries queued
31
+ */
32
+ async processIncoming(
33
+ payload: HeliusWebhookTransaction | HeliusWebhookTransaction[],
34
+ headers: { signature?: string; rawBody?: string }
35
+ ): Promise<number> {
36
+ // Verify Helius signature if secret is configured
37
+ if (env.HELIUS_WEBHOOK_SECRET && headers.rawBody) {
38
+ const valid = verifyWebhookSignature(
39
+ headers.rawBody,
40
+ headers.signature,
41
+ env.HELIUS_WEBHOOK_SECRET
42
+ )
43
+ if (!valid) {
44
+ logger.warn('Helius webhook signature verification failed')
45
+ throw new Error('HELIUS_SIGNATURE_INVALID')
46
+ }
47
+ }
48
+
49
+ const transactions = Array.isArray(payload) ? payload : [payload]
50
+ const registrations = webhookStore.getAll()
51
+
52
+ if (registrations.length === 0) {
53
+ logger.debug('No active webhook registrations, skipping processing')
54
+ return 0
55
+ }
56
+
57
+ let matchCount = 0
58
+
59
+ for (const tx of transactions) {
60
+ for (const registration of registrations) {
61
+ try {
62
+ const payment = await processWebhookTransaction(
63
+ tx,
64
+ registration.viewingPrivateKey,
65
+ registration.spendingPublicKey
66
+ )
67
+
68
+ if (payment) {
69
+ matchCount++
70
+ webhookDeliveryService.queueDelivery(registration, payment)
71
+ logger.info(
72
+ {
73
+ webhookId: registration.id,
74
+ txSignature: payment.txSignature,
75
+ },
76
+ 'Payment matched, delivery queued'
77
+ )
78
+ }
79
+ } catch (error) {
80
+ logger.warn(
81
+ {
82
+ webhookId: registration.id,
83
+ error: (error as Error).message,
84
+ },
85
+ 'Error checking transaction against registration'
86
+ )
87
+ }
88
+ }
89
+ }
90
+
91
+ logger.info(
92
+ {
93
+ transactions: transactions.length,
94
+ registrations: registrations.length,
95
+ matches: matchCount,
96
+ },
97
+ 'Helius webhook processed'
98
+ )
99
+
100
+ return matchCount
101
+ }
102
+ }
103
+
104
+ export const heliusListenerService = new HeliusListenerService()
@@ -0,0 +1,15 @@
1
+ export {
2
+ getTokenMetadata,
3
+ getTokenDecimals,
4
+ isKnownToken,
5
+ type TokenMetadata,
6
+ } from './token-metadata'
7
+ export {
8
+ WebhookDeliveryService,
9
+ webhookDeliveryService,
10
+ computeHmacSignature,
11
+ } from './webhook-delivery'
12
+ export {
13
+ HeliusListenerService,
14
+ heliusListenerService,
15
+ } from './helius-listener'
@@ -0,0 +1,91 @@
1
+ import type { ChainId } from '@sip-protocol/types'
2
+
3
+ /**
4
+ * Token metadata interface
5
+ */
6
+ export interface TokenMetadata {
7
+ symbol: string
8
+ decimals: number
9
+ name: string
10
+ address?: string
11
+ }
12
+
13
+ /**
14
+ * Token registry with known tokens and their metadata
15
+ *
16
+ * IMPORTANT: This is a static registry for common tokens.
17
+ * In production, fetch metadata from on-chain or token list APIs.
18
+ */
19
+ const TOKEN_REGISTRY: Record<string, Record<string, TokenMetadata>> = {
20
+ // Solana tokens
21
+ solana: {
22
+ SOL: { symbol: 'SOL', decimals: 9, name: 'Solana' },
23
+ USDC: { symbol: 'USDC', decimals: 6, name: 'USD Coin', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' },
24
+ USDT: { symbol: 'USDT', decimals: 6, name: 'Tether USD', address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' },
25
+ RAY: { symbol: 'RAY', decimals: 6, name: 'Raydium', address: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R' },
26
+ BONK: { symbol: 'BONK', decimals: 5, name: 'Bonk', address: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263' },
27
+ JUP: { symbol: 'JUP', decimals: 6, name: 'Jupiter', address: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' },
28
+ },
29
+ // Ethereum tokens
30
+ ethereum: {
31
+ ETH: { symbol: 'ETH', decimals: 18, name: 'Ethereum' },
32
+ USDC: { symbol: 'USDC', decimals: 6, name: 'USD Coin', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' },
33
+ USDT: { symbol: 'USDT', decimals: 6, name: 'Tether USD', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' },
34
+ WETH: { symbol: 'WETH', decimals: 18, name: 'Wrapped Ether', address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' },
35
+ DAI: { symbol: 'DAI', decimals: 18, name: 'Dai Stablecoin', address: '0x6B175474E89094C44Da98b954EesdeB131e560dA7' },
36
+ WBTC: { symbol: 'WBTC', decimals: 8, name: 'Wrapped Bitcoin', address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' },
37
+ },
38
+ // NEAR tokens
39
+ near: {
40
+ NEAR: { symbol: 'NEAR', decimals: 24, name: 'NEAR Protocol' },
41
+ USDC: { symbol: 'USDC', decimals: 6, name: 'USD Coin', address: 'usdc.near' },
42
+ USDT: { symbol: 'USDT', decimals: 6, name: 'Tether USD', address: 'usdt.near' },
43
+ wNEAR: { symbol: 'wNEAR', decimals: 24, name: 'Wrapped NEAR', address: 'wrap.near' },
44
+ },
45
+ // Bitcoin (mostly for reference)
46
+ bitcoin: {
47
+ BTC: { symbol: 'BTC', decimals: 8, name: 'Bitcoin' },
48
+ },
49
+ }
50
+
51
+ /**
52
+ * Get token metadata for a given chain and symbol
53
+ *
54
+ * @throws Error if token not found (never silently return wrong decimals)
55
+ */
56
+ export function getTokenMetadata(chain: ChainId, symbol: string): TokenMetadata {
57
+ const chainTokens = TOKEN_REGISTRY[chain]
58
+ if (!chainTokens) {
59
+ throw new Error(`Unknown chain: ${chain}. Supported chains: ${Object.keys(TOKEN_REGISTRY).join(', ')}`)
60
+ }
61
+
62
+ // Case-insensitive lookup
63
+ const normalizedSymbol = symbol.toUpperCase()
64
+ const token = chainTokens[normalizedSymbol]
65
+ if (!token) {
66
+ throw new Error(`Unknown token ${symbol} on ${chain}. Known tokens: ${Object.keys(chainTokens).join(', ')}`)
67
+ }
68
+
69
+ return token
70
+ }
71
+
72
+ /**
73
+ * Get token decimals for a given chain and symbol
74
+ *
75
+ * @throws Error if token not found (never silently return wrong decimals)
76
+ */
77
+ export function getTokenDecimals(chain: ChainId, symbol: string): number {
78
+ return getTokenMetadata(chain, symbol).decimals
79
+ }
80
+
81
+ /**
82
+ * Check if token exists in registry
83
+ */
84
+ export function isKnownToken(chain: ChainId, symbol: string): boolean {
85
+ try {
86
+ getTokenMetadata(chain, symbol)
87
+ return true
88
+ } catch {
89
+ return false
90
+ }
91
+ }