@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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Delivery Service
|
|
3
|
+
*
|
|
4
|
+
* Delivers payment notifications to registered agent URLs with:
|
|
5
|
+
* - HMAC-SHA256 signature (X-SIP-Signature header)
|
|
6
|
+
* - Exponential backoff retry (1s, 4s, 16s)
|
|
7
|
+
* - Graceful shutdown drain
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { hmac } from '@noble/hashes/hmac'
|
|
11
|
+
import { sha256 } from '@noble/hashes/sha256'
|
|
12
|
+
import { bytesToHex } from '@noble/hashes/utils'
|
|
13
|
+
import { logger } from '../logger'
|
|
14
|
+
import { env } from '../config'
|
|
15
|
+
import type { WebhookDeliveryPayload } from '../types/api'
|
|
16
|
+
import type { WebhookRegistration } from '../stores/webhook-store'
|
|
17
|
+
import type { SolanaScanResult } from '@sip-protocol/sdk'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute HMAC-SHA256 signature for webhook payload
|
|
21
|
+
*/
|
|
22
|
+
export function computeHmacSignature(secret: string, body: string): string {
|
|
23
|
+
const encoder = new TextEncoder()
|
|
24
|
+
const sig = bytesToHex(hmac(sha256, encoder.encode(secret), encoder.encode(body)))
|
|
25
|
+
return `sha256=${sig}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sleep helper for retry backoff
|
|
30
|
+
*/
|
|
31
|
+
function sleep(ms: number): Promise<void> {
|
|
32
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class WebhookDeliveryService {
|
|
36
|
+
private readonly maxRetries: number
|
|
37
|
+
private pendingDeliveries = new Set<Promise<void>>()
|
|
38
|
+
|
|
39
|
+
constructor(maxRetries?: number) {
|
|
40
|
+
this.maxRetries = maxRetries ?? env.WEBHOOK_DELIVERY_MAX_RETRIES
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the delivery payload from a scan result
|
|
45
|
+
*/
|
|
46
|
+
buildPayload(registration: WebhookRegistration, payment: SolanaScanResult): WebhookDeliveryPayload {
|
|
47
|
+
return {
|
|
48
|
+
event: 'payment.received',
|
|
49
|
+
webhookId: registration.id,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
data: {
|
|
52
|
+
txSignature: payment.txSignature,
|
|
53
|
+
stealthAddress: payment.stealthAddress,
|
|
54
|
+
ephemeralPublicKey: payment.ephemeralPublicKey,
|
|
55
|
+
amount: payment.amount.toString(),
|
|
56
|
+
mint: payment.mint,
|
|
57
|
+
tokenSymbol: payment.tokenSymbol,
|
|
58
|
+
slot: payment.slot,
|
|
59
|
+
blockTime: payment.timestamp,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deliver a payment notification to a registered webhook
|
|
66
|
+
*
|
|
67
|
+
* Retries with exponential backoff on failure.
|
|
68
|
+
*/
|
|
69
|
+
async deliver(registration: WebhookRegistration, payment: SolanaScanResult): Promise<boolean> {
|
|
70
|
+
const payload = this.buildPayload(registration, payment)
|
|
71
|
+
const body = JSON.stringify(payload)
|
|
72
|
+
const signature = computeHmacSignature(registration.secret, body)
|
|
73
|
+
|
|
74
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(registration.url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'X-SIP-Signature': signature,
|
|
81
|
+
'X-SIP-Webhook-Id': registration.id,
|
|
82
|
+
},
|
|
83
|
+
body,
|
|
84
|
+
signal: AbortSignal.timeout(10_000),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (response.ok) {
|
|
88
|
+
logger.info(
|
|
89
|
+
{ webhookId: registration.id, attempt, status: response.status },
|
|
90
|
+
'Webhook delivered'
|
|
91
|
+
)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger.warn(
|
|
96
|
+
{ webhookId: registration.id, attempt, status: response.status },
|
|
97
|
+
'Webhook delivery failed (non-2xx)'
|
|
98
|
+
)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logger.warn(
|
|
101
|
+
{ webhookId: registration.id, attempt, error: (error as Error).message },
|
|
102
|
+
'Webhook delivery error'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (attempt < this.maxRetries) {
|
|
107
|
+
const backoffMs = Math.pow(4, attempt) * 1000 // 1s, 4s, 16s
|
|
108
|
+
await sleep(backoffMs)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.error(
|
|
113
|
+
{ webhookId: registration.id, maxRetries: this.maxRetries },
|
|
114
|
+
'Webhook delivery exhausted all retries'
|
|
115
|
+
)
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Queue a delivery (fire-and-forget with tracking)
|
|
121
|
+
*/
|
|
122
|
+
queueDelivery(registration: WebhookRegistration, payment: SolanaScanResult): void {
|
|
123
|
+
const promise: Promise<void> = this.deliver(registration, payment)
|
|
124
|
+
.then(() => {})
|
|
125
|
+
.catch(err => {
|
|
126
|
+
logger.error({ webhookId: registration.id, err }, 'Unhandled webhook delivery error')
|
|
127
|
+
})
|
|
128
|
+
.finally(() => {
|
|
129
|
+
this.pendingDeliveries.delete(promise)
|
|
130
|
+
})
|
|
131
|
+
this.pendingDeliveries.add(promise)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wait for all pending deliveries to complete (graceful shutdown)
|
|
136
|
+
*/
|
|
137
|
+
async drainPending(): Promise<void> {
|
|
138
|
+
if (this.pendingDeliveries.size > 0) {
|
|
139
|
+
logger.info({ pending: this.pendingDeliveries.size }, 'Draining pending webhook deliveries')
|
|
140
|
+
await Promise.allSettled(this.pendingDeliveries)
|
|
141
|
+
logger.info('All webhook deliveries drained')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const webhookDeliveryService = new WebhookDeliveryService()
|
package/src/shutdown.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful Shutdown Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles SIGTERM and SIGINT signals to:
|
|
5
|
+
* - Stop accepting new connections
|
|
6
|
+
* - Allow in-flight requests to complete
|
|
7
|
+
* - Clean up resources
|
|
8
|
+
* - Exit gracefully
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Server } from 'http'
|
|
12
|
+
import { logger } from './logger'
|
|
13
|
+
import { env } from './config'
|
|
14
|
+
|
|
15
|
+
let isShuttingDown = false
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if server is currently shutting down
|
|
19
|
+
*/
|
|
20
|
+
export function isServerShuttingDown(): boolean {
|
|
21
|
+
return isShuttingDown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Setup graceful shutdown handlers
|
|
26
|
+
*
|
|
27
|
+
* @param server - HTTP server instance
|
|
28
|
+
* @param cleanup - Optional cleanup function to run before exit
|
|
29
|
+
*/
|
|
30
|
+
export function setupGracefulShutdown(
|
|
31
|
+
server: Server,
|
|
32
|
+
cleanup?: () => Promise<void>
|
|
33
|
+
): void {
|
|
34
|
+
const shutdown = async (signal: string) => {
|
|
35
|
+
if (isShuttingDown) {
|
|
36
|
+
logger.warn({ signal }, 'Shutdown already in progress, ignoring signal')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isShuttingDown = true
|
|
41
|
+
logger.info({ signal }, 'Received shutdown signal, starting graceful shutdown...')
|
|
42
|
+
|
|
43
|
+
// Stop accepting new connections
|
|
44
|
+
server.close(async (err) => {
|
|
45
|
+
if (err) {
|
|
46
|
+
logger.error({ err }, 'Error closing HTTP server')
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logger.info('HTTP server closed, no longer accepting connections')
|
|
51
|
+
|
|
52
|
+
// Run cleanup if provided
|
|
53
|
+
if (cleanup) {
|
|
54
|
+
try {
|
|
55
|
+
logger.info('Running cleanup tasks...')
|
|
56
|
+
await cleanup()
|
|
57
|
+
logger.info('Cleanup completed successfully')
|
|
58
|
+
} catch (cleanupErr) {
|
|
59
|
+
logger.error({ err: cleanupErr }, 'Error during cleanup')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
logger.info('Graceful shutdown complete, exiting')
|
|
64
|
+
process.exit(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Force exit after timeout
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
logger.error(
|
|
70
|
+
{ timeoutMs: env.SHUTDOWN_TIMEOUT_MS },
|
|
71
|
+
'Graceful shutdown timeout exceeded, forcing exit'
|
|
72
|
+
)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}, env.SHUTDOWN_TIMEOUT_MS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle termination signals
|
|
78
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
79
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
80
|
+
|
|
81
|
+
// Handle uncaught errors
|
|
82
|
+
process.on('uncaughtException', (err) => {
|
|
83
|
+
logger.fatal({ err }, 'Uncaught exception, shutting down')
|
|
84
|
+
shutdown('uncaughtException')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
process.on('unhandledRejection', (reason) => {
|
|
88
|
+
logger.fatal({ reason }, 'Unhandled rejection, shutting down')
|
|
89
|
+
shutdown('unhandledRejection')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
logger.debug('Graceful shutdown handlers registered')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Middleware to reject new requests during shutdown
|
|
97
|
+
*/
|
|
98
|
+
export function shutdownMiddleware(
|
|
99
|
+
req: { path: string },
|
|
100
|
+
res: { status: (code: number) => { json: (body: unknown) => void } },
|
|
101
|
+
next: () => void
|
|
102
|
+
): void {
|
|
103
|
+
if (isShuttingDown) {
|
|
104
|
+
// Allow health checks to report unhealthy during shutdown
|
|
105
|
+
if (req.path === '/api/v1/health') {
|
|
106
|
+
return next()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
res.status(503).json({
|
|
110
|
+
success: false,
|
|
111
|
+
error: {
|
|
112
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
113
|
+
message: 'Server is shutting down',
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
next()
|
|
119
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { LRUCache } from 'lru-cache'
|
|
2
|
+
import type { HexString } from '@sip-protocol/types'
|
|
3
|
+
import { logger } from '../logger'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Swap data stored in the cache
|
|
7
|
+
*/
|
|
8
|
+
export interface SwapData {
|
|
9
|
+
id: string
|
|
10
|
+
status: 'pending' | 'processing' | 'completed' | 'failed'
|
|
11
|
+
transactionHash?: HexString
|
|
12
|
+
inputAmount: string
|
|
13
|
+
outputAmount?: string
|
|
14
|
+
createdAt: string
|
|
15
|
+
updatedAt: string
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Swap store configuration
|
|
21
|
+
*/
|
|
22
|
+
export interface SwapStoreConfig {
|
|
23
|
+
/** Maximum number of swaps to store (default: 10000) */
|
|
24
|
+
maxSize?: number
|
|
25
|
+
/** TTL in milliseconds (default: 24 hours) */
|
|
26
|
+
ttlMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MAX_SIZE = 10_000
|
|
30
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* LRU cache-backed swap store with TTL
|
|
34
|
+
*
|
|
35
|
+
* Features:
|
|
36
|
+
* - Bounded memory (max 10,000 entries by default)
|
|
37
|
+
* - Automatic TTL-based expiration (24 hours by default)
|
|
38
|
+
* - LRU eviction when at capacity
|
|
39
|
+
* - Metrics for monitoring
|
|
40
|
+
*/
|
|
41
|
+
export class SwapStore {
|
|
42
|
+
private cache: LRUCache<string, SwapData>
|
|
43
|
+
private readonly maxSize: number
|
|
44
|
+
private readonly ttlMs: number
|
|
45
|
+
|
|
46
|
+
constructor(config: SwapStoreConfig = {}) {
|
|
47
|
+
this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE
|
|
48
|
+
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS
|
|
49
|
+
|
|
50
|
+
this.cache = new LRUCache<string, SwapData>({
|
|
51
|
+
max: this.maxSize,
|
|
52
|
+
ttl: this.ttlMs,
|
|
53
|
+
updateAgeOnGet: false, // Don't reset TTL on read
|
|
54
|
+
updateAgeOnHas: false,
|
|
55
|
+
// Log when items are evicted or expired
|
|
56
|
+
disposeAfter: (_value, key, reason) => {
|
|
57
|
+
if (reason === 'evict') {
|
|
58
|
+
logger.debug({ swapId: key, reason }, 'Swap evicted from cache (LRU)')
|
|
59
|
+
} else if (reason === 'expire') {
|
|
60
|
+
logger.debug({ swapId: key, reason }, 'Swap expired from cache (TTL)')
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
{ maxSize: this.maxSize, ttlMs: this.ttlMs },
|
|
67
|
+
'SwapStore initialized'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a swap by ID
|
|
73
|
+
*/
|
|
74
|
+
get(id: string): SwapData | undefined {
|
|
75
|
+
return this.cache.get(id)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a swap exists
|
|
80
|
+
*/
|
|
81
|
+
has(id: string): boolean {
|
|
82
|
+
return this.cache.has(id)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set/update a swap
|
|
87
|
+
*/
|
|
88
|
+
set(id: string, data: SwapData): void {
|
|
89
|
+
this.cache.set(id, data)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete a swap
|
|
94
|
+
*/
|
|
95
|
+
delete(id: string): boolean {
|
|
96
|
+
return this.cache.delete(id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update swap status
|
|
101
|
+
*/
|
|
102
|
+
updateStatus(
|
|
103
|
+
id: string,
|
|
104
|
+
status: SwapData['status'],
|
|
105
|
+
updates?: Partial<SwapData>
|
|
106
|
+
): SwapData | undefined {
|
|
107
|
+
const existing = this.cache.get(id)
|
|
108
|
+
if (!existing) {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const updated: SwapData = {
|
|
113
|
+
...existing,
|
|
114
|
+
...updates,
|
|
115
|
+
status,
|
|
116
|
+
updatedAt: new Date().toISOString(),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.cache.set(id, updated)
|
|
120
|
+
return updated
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get store metrics for monitoring
|
|
125
|
+
*/
|
|
126
|
+
getMetrics(): {
|
|
127
|
+
size: number
|
|
128
|
+
maxSize: number
|
|
129
|
+
ttlMs: number
|
|
130
|
+
utilizationPercent: number
|
|
131
|
+
} {
|
|
132
|
+
const size = this.cache.size
|
|
133
|
+
return {
|
|
134
|
+
size,
|
|
135
|
+
maxSize: this.maxSize,
|
|
136
|
+
ttlMs: this.ttlMs,
|
|
137
|
+
utilizationPercent: Math.round((size / this.maxSize) * 100),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear all swaps (for testing)
|
|
143
|
+
*/
|
|
144
|
+
clear(): void {
|
|
145
|
+
this.cache.clear()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Purge stale entries (normally handled automatically by LRU cache)
|
|
150
|
+
*/
|
|
151
|
+
purgeStale(): void {
|
|
152
|
+
this.cache.purgeStale()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Export singleton instance with default config
|
|
157
|
+
// Can be overridden in tests or for custom configurations
|
|
158
|
+
export const swapStore = new SwapStore()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Webhook Registration Store
|
|
3
|
+
*
|
|
4
|
+
* Stores agent webhook registrations with bounded capacity.
|
|
5
|
+
* Data is lost on restart (MVP limitation — future: Redis/SQLite).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from 'crypto'
|
|
9
|
+
import type { HexString } from '@sip-protocol/types'
|
|
10
|
+
import { logger } from '../logger'
|
|
11
|
+
import { env } from '../config'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Webhook registration entry
|
|
15
|
+
*/
|
|
16
|
+
export interface WebhookRegistration {
|
|
17
|
+
id: string
|
|
18
|
+
url: string
|
|
19
|
+
viewingPrivateKey: HexString
|
|
20
|
+
spendingPublicKey: HexString
|
|
21
|
+
secret: string
|
|
22
|
+
createdAt: string
|
|
23
|
+
active: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Bounded in-memory store for webhook registrations
|
|
28
|
+
*/
|
|
29
|
+
export class WebhookStore {
|
|
30
|
+
private registrations = new Map<string, WebhookRegistration>()
|
|
31
|
+
private readonly maxSize: number
|
|
32
|
+
|
|
33
|
+
constructor(maxSize?: number) {
|
|
34
|
+
this.maxSize = maxSize ?? env.WEBHOOK_STORE_MAX_SIZE
|
|
35
|
+
logger.info({ maxSize: this.maxSize }, 'WebhookStore initialized')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register a new webhook
|
|
40
|
+
*
|
|
41
|
+
* @returns The registration with secret (shown once only)
|
|
42
|
+
*/
|
|
43
|
+
register(url: string, viewingPrivateKey: HexString, spendingPublicKey: HexString): WebhookRegistration {
|
|
44
|
+
if (this.registrations.size >= this.maxSize) {
|
|
45
|
+
throw new Error('WEBHOOK_STORE_FULL')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const id = randomBytes(16).toString('hex')
|
|
49
|
+
const secret = randomBytes(32).toString('hex')
|
|
50
|
+
|
|
51
|
+
const registration: WebhookRegistration = {
|
|
52
|
+
id,
|
|
53
|
+
url,
|
|
54
|
+
viewingPrivateKey,
|
|
55
|
+
spendingPublicKey,
|
|
56
|
+
secret,
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
active: true,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.registrations.set(id, registration)
|
|
62
|
+
logger.info({ webhookId: id, url }, 'Webhook registered')
|
|
63
|
+
return registration
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unregister a webhook by ID
|
|
68
|
+
*
|
|
69
|
+
* @returns true if found and removed, false if not found
|
|
70
|
+
*/
|
|
71
|
+
unregister(id: string): boolean {
|
|
72
|
+
const existed = this.registrations.delete(id)
|
|
73
|
+
if (existed) {
|
|
74
|
+
logger.info({ webhookId: id }, 'Webhook unregistered')
|
|
75
|
+
}
|
|
76
|
+
return existed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get a registration by ID
|
|
81
|
+
*/
|
|
82
|
+
get(id: string): WebhookRegistration | undefined {
|
|
83
|
+
return this.registrations.get(id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get all active registrations
|
|
88
|
+
*/
|
|
89
|
+
getAll(): WebhookRegistration[] {
|
|
90
|
+
return Array.from(this.registrations.values()).filter(r => r.active)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get all registrations (including inactive) for listing
|
|
95
|
+
*/
|
|
96
|
+
getAllForList(): Array<{ id: string; url: string; active: boolean; createdAt: string }> {
|
|
97
|
+
return Array.from(this.registrations.values()).map(r => ({
|
|
98
|
+
id: r.id,
|
|
99
|
+
url: r.url,
|
|
100
|
+
active: r.active,
|
|
101
|
+
createdAt: r.createdAt,
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Current store size
|
|
107
|
+
*/
|
|
108
|
+
get size(): number {
|
|
109
|
+
return this.registrations.size
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Clear all registrations (for testing)
|
|
114
|
+
*/
|
|
115
|
+
clear(): void {
|
|
116
|
+
this.registrations.clear()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const webhookStore = new WebhookStore()
|
package/src/types/api.ts
CHANGED
|
@@ -17,10 +17,28 @@ export interface ApiResponse<T = unknown> {
|
|
|
17
17
|
* Health check response
|
|
18
18
|
*/
|
|
19
19
|
export interface HealthResponse {
|
|
20
|
-
status: 'healthy' | 'unhealthy'
|
|
20
|
+
status: 'healthy' | 'unhealthy' | 'shutting_down'
|
|
21
21
|
version: string
|
|
22
22
|
timestamp: string
|
|
23
23
|
uptime: number
|
|
24
|
+
services?: {
|
|
25
|
+
proofProvider: {
|
|
26
|
+
ready: boolean
|
|
27
|
+
error: string | null
|
|
28
|
+
}
|
|
29
|
+
rateLimiter?: {
|
|
30
|
+
store: 'redis' | 'memory'
|
|
31
|
+
redisConfigured: boolean
|
|
32
|
+
redisConnected: boolean
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
cache?: {
|
|
36
|
+
swaps: {
|
|
37
|
+
size: number
|
|
38
|
+
maxSize: number
|
|
39
|
+
utilizationPercent: number
|
|
40
|
+
}
|
|
41
|
+
}
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
/**
|
|
@@ -121,3 +139,51 @@ export interface SwapStatusResponse {
|
|
|
121
139
|
updatedAt: string
|
|
122
140
|
error?: string
|
|
123
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Webhook registration request
|
|
145
|
+
*/
|
|
146
|
+
export interface RegisterWebhookRequest {
|
|
147
|
+
url: string
|
|
148
|
+
viewingPrivateKey: HexString
|
|
149
|
+
spendingPublicKey: HexString
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Webhook registration response (secret shown once only)
|
|
154
|
+
*/
|
|
155
|
+
export interface RegisterWebhookResponse {
|
|
156
|
+
id: string
|
|
157
|
+
url: string
|
|
158
|
+
secret: string
|
|
159
|
+
createdAt: string
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Webhook list item (never exposes keys or secret)
|
|
164
|
+
*/
|
|
165
|
+
export interface WebhookListItem {
|
|
166
|
+
id: string
|
|
167
|
+
url: string
|
|
168
|
+
active: boolean
|
|
169
|
+
createdAt: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Webhook delivery payload sent to agent URLs
|
|
174
|
+
*/
|
|
175
|
+
export interface WebhookDeliveryPayload {
|
|
176
|
+
event: 'payment.received'
|
|
177
|
+
webhookId: string
|
|
178
|
+
timestamp: string
|
|
179
|
+
data: {
|
|
180
|
+
txSignature: string
|
|
181
|
+
stealthAddress: string
|
|
182
|
+
ephemeralPublicKey: string
|
|
183
|
+
amount: string
|
|
184
|
+
mint: string
|
|
185
|
+
tokenSymbol?: string
|
|
186
|
+
slot: number
|
|
187
|
+
blockTime: number
|
|
188
|
+
}
|
|
189
|
+
}
|