@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,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
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
31
|
-
app.use(
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
║
|
|
70
|
-
║
|
|
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
|
+
}
|